Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Invoice #17

Merged
merged 7 commits into from
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/backend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const registerCacheModule = () =>
port: Number(config.getOrThrow('REDIS_PORT')),
},
username: config.get('REDIS_USER'),
password: config.get('REDIS_PASSWORD')
password: config.get('REDIS_PASSWORD'),
});

return {
Expand Down
13 changes: 9 additions & 4 deletions apps/backend/src/checkout-session/checkout-session.processer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export const SuperfluidStripeSubscriptionsMetadataSchema: z.ZodType<SuperfluidSt
superfluid_token_address: AddressSchema,
superfluid_sender_address: AddressSchema,
superfluid_receiver_address: AddressSchema,
superfluid_require_upfront_transfer: z.string().toLowerCase().pipe(z.literal("true").or(z.literal("false")))
superfluid_require_upfront_transfer: z
.string()
.toLowerCase()
.pipe(z.literal('true').or(z.literal('false'))),
})
.strip();

Expand Down Expand Up @@ -151,13 +154,15 @@ export class CheckoutSessionProcesser extends WorkerHost {
superfluid_token_address: data.superTokenAddress as Address,
superfluid_sender_address: data.senderAddress as Address,
superfluid_receiver_address: data.receiverAddress as Address,
superfluid_require_upfront_transfer: requireUpfrontTransfer.toString()
superfluid_require_upfront_transfer: requireUpfrontTransfer.toString(),
};

// Note that we are creating a Stripe Subscription here that will send invoices and e-mails to the user.
// There could be scenarios where someone was using the checkout widget to pay for an existing subscription.
// Then we wouldn't want to create a new subscription here...
const daysUntilDue = requireUpfrontTransfer ? 0 : mapTimePeriodToSeconds(price.recurring!.interval) / SECONDS_IN_A_DAY
const daysUntilDue = requireUpfrontTransfer
? 0
: mapTimePeriodToSeconds(price.recurring!.interval) / SECONDS_IN_A_DAY;
const subscriptionsCreateParams: Stripe.SubscriptionCreateParams = {
customer: customerId,
collection_method: 'send_invoice',
Expand All @@ -178,7 +183,7 @@ export class CheckoutSessionProcesser extends WorkerHost {
],
metadata: subscriptionMetadata,
};

const subscriptionsCreateResponse =
await this.stripeClient.subscriptions.create(subscriptionsCreateParams);

Expand Down
12 changes: 6 additions & 6 deletions apps/backend/src/checkout-session/time-period.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
export const timePeriods = ["day", "week", "month", "year"] as const;
export const timePeriods = ['day', 'week', 'month', 'year'] as const;

export const SECONDS_IN_A_DAY = 86_400n; // 60 * 60 * 24 seconds in a day

// TODO(KK): get this from the widget repo but might need to handle dual packages first
export const mapTimePeriodToSeconds = (timePeriod: TimePeriod): bigint => {
switch (timePeriod) {
case "day":
case 'day':
return SECONDS_IN_A_DAY;
case "week":
case 'week':
return 604800n; // 60 * 60 * 24 * 7 seconds in a week
case "month":
case 'month':
return 2628000n; // 60 * 60 * 24 * 30 seconds in a month (approximation)
case "year":
case 'year':
return 31536000n; // 60 * 60 * 24 * 365 seconds in a year (approximation)
default:
throw new Error(`Invalid time period: ${timePeriod}`);
}
};

export type TimePeriod = (typeof timePeriods)[number];
export type TimePeriod = (typeof timePeriods)[number];
2 changes: 1 addition & 1 deletion apps/backend/src/stripe-listener/stripe-listener.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class StripeListenerModule {
pattern: '*/3 * * * *', // Repeat every minute. Check with: https://crontab.guru/
},
removeOnComplete: 50,
removeOnFail: 50
removeOnFail: 50,
}, // options
);
logger.debug('onModuleInit');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const DEFAULT_LOOK_AND_FEEL_CUSTOMER = {
name: LOOK_AND_FEEL_CUSTOMER_NAME,
description: 'Auto-generated fake customer for Superfluid integration.',
metadata: {
theme: `{"palette":{"mode":"light","primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}}`,
theme: `{"components":{"MuiButton":{"styleOverrides":{"root":{"borderRadius":100}}}},"palette":{"mode":"light","primary":{"main":"#0074d4"},"secondary":{"main":"#f50057"}}}`,
},
} as const satisfies Stripe.CustomerCreateParams;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,23 @@ import {
} from './superfluid-stripe-config/superfluid-stripe-config.service';

type StripeProductWithPriceExpanded = Stripe.Product & {
default_price: Stripe.Price
}
default_price: Stripe.Price;
};

type ProductResponse = {
stripeProduct: Stripe.Product;
productDetails: WidgetProps['productDetails'];
paymentDetails: WidgetProps['paymentDetails'];
};

type InvoiceResponse = {
stripeInvoice: Stripe.Invoice;
productConfig: ProductResponse;
};

type ProductsResponse = (ProductResponse & {
stripeProduct: StripeProductWithPriceExpanded
})[]
stripeProduct: StripeProductWithPriceExpanded;
})[];

@Controller('superfluid-stripe-converter')
export class SuperfluidStripeConverterController {
Expand All @@ -41,7 +46,7 @@ export class SuperfluidStripeConverterController {
this.stripeClient.prices
.list({
product: productId,
active: true
active: true,
})
.autoPagingToArray(DEFAULT_PAGING),
this.superfluidStripeConfigService.loadOrInitializeBlockchainConfig(),
Expand All @@ -64,7 +69,7 @@ export class SuperfluidStripeConverterController {
this.stripeClient.products
.list({
active: true,
expand: ["data.default_price"]
expand: ['data.default_price'],
})
.autoPagingToArray(DEFAULT_PAGING),
this.stripeClient.prices
Expand Down Expand Up @@ -104,6 +109,21 @@ export class SuperfluidStripeConverterController {
await this.superfluidStripeConfigService.loadOrInitializeLookAndFeelConfig();
return lookAndFeelConfig;
}

@Get('invoice')
async invoice(@Query('invoice-id') invoiceId: string): Promise<InvoiceResponse> {
const stripeInvoice = await this.stripeClient.invoices.retrieve(invoiceId);
const product = await this.stripeClient.products.retrieve(
stripeInvoice.lines.data[0].price?.product as string,
);

const productConfig = await this.mapStripeProductToCheckoutWidget(product.id);

return {
stripeInvoice,
productConfig,
};
}
}

const logger = new Logger(SuperfluidStripeConverterController.name);
6 changes: 1 addition & 5 deletions apps/frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions apps/frontend/src/backend-openapi-client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export interface paths {
'/superfluid-stripe-converter/look-and-feel': {
get: operations['SuperfluidStripeConverterController_lookAndFeel'];
};
'/superfluid-stripe-converter/invoice': {
get: operations['SuperfluidStripeConverterController_invoice'];
};
'/health': {
get: operations['HealthController_check'];
};
Expand Down Expand Up @@ -104,6 +107,18 @@ export interface operations {
};
};
};
SuperfluidStripeConverterController_invoice: {
parameters: {
query: {
'invoice-id': string;
};
};
responses: {
200: {
content: never;
};
};
};
HealthController_check: {
responses: {
/** @description The Health Check is successful */
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/src/components/SuperfluidWidgetProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function SupefluidWidgetProvider({
const [paymentOption, setPaymentOption] = useState<PaymentOption | undefined>();
const { address: accountAddress } = useAccount();

const eventListeners = useMemo<EventListeners>(
const eventListeners = useMemo<EventListeners>(
() => ({
onPaymentOptionUpdate: (paymentOption) => setPaymentOption(paymentOption),
onRouteChange: (arg) => {
Expand All @@ -64,7 +64,7 @@ export default function SupefluidWidgetProvider({
senderAddress: accountAddress,
receiverAddress: paymentOption.receiverAddress,
email: email,
idempotencyKey: idempotencyKey
idempotencyKey: idempotencyKey,
};
createSession(data);
}
Expand All @@ -86,4 +86,4 @@ export default function SupefluidWidgetProvider({
);
}

const idempotencyKey = Math.random().toString(20).substr(2, 8); // Random key, generated once per front-end initialization.
const idempotencyKey = Math.random().toString(20).substr(2, 8); // Random key, generated once per front-end initialization.
5 changes: 4 additions & 1 deletion apps/frontend/src/internalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ type InternalConfig = {

const internalConfig: InternalConfig = {
getApiKey() {
const apiKey = ensureDefined(process.env.STRIPE_SECRET_KEY ?? process.env.INTERNAL_API_KEY, "STRIPE_SECRET_KEY or INTERNAL_API_KEY");
const apiKey = ensureDefined(
process.env.STRIPE_SECRET_KEY ?? process.env.INTERNAL_API_KEY,
'STRIPE_SECRET_KEY or INTERNAL_API_KEY',
);
return apiKey;
},
getBackendBaseUrl() {
Expand Down
51 changes: 18 additions & 33 deletions apps/frontend/src/pages/[product].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,15 @@ import Layout from '@/components/Layout';
import SupefluidWidgetProvider from '@/components/SuperfluidWidgetProvider';
import WagmiProvider from '@/components/WagmiProvider';
import internalConfig from '@/internalConfig';
import {
Box,
Button,
Container,
IconButton,
Link,
Stack,
ThemeOptions,
Toolbar,
} from '@mui/material';
import { WidgetProps } from '@superfluid-finance/widget';
import { Button, Container, IconButton, ThemeOptions, Typography } from '@mui/material';
import { GetServerSideProps } from 'next';
import { use, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { paths } from '@/backend-openapi-client';
import createClient from 'openapi-fetch';
import { LookAndFeelConfig, ProductConfig } from './pricing';
import { EmailField } from '@superfluid-finance/widget/utils';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import NextLink from 'next/link';
import Link from '@/Link';

type Props = {
productConfig: ProductConfig;
Expand All @@ -30,39 +21,33 @@ type Props = {
export default function Product({ productConfig, theme }: Props) {
// TODO(KK): validate params?

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);

// TODO(KK): handle theme any
return (
<Layout themeOptions={theme}>
{/* TODO(KK): check if pricing table is enabled */}

<Container sx={{ mb: 2.5 }}>
<Button component={NextLink} href="/pricing" color="primary" startIcon={<ArrowBackIcon />}>
Back to products
</Button>
<IconButton
LinkComponent={Link}
href="/pricing"
title="Back"
edge="start"
size="large"
sx={(theme) => ({ color: theme.palette.text.secondary })}
>
<ArrowBackIcon fontSize="small" />
</IconButton>
</Container>

<WagmiProvider>
<ConnectKitProvider mode={theme.palette?.mode}>
{!!productConfig && mounted && (
{!!productConfig && (
<SupefluidWidgetProvider
productId={productConfig.stripeProduct.id}
productDetails={productConfig.productDetails}
paymentDetails={productConfig.paymentDetails}
theme={theme}
personalData={[
{
name: 'email',
label: 'Email',
required: {
pattern: '/^([a-zA-Z0-9_\\-\\.]+)@([a-zA-Z0-9_\\-\\.]+)\\.([a-zA-Z]{2,5})$/g',
message: 'Invalid email address',
},
},

//This doesn't work
// EmailField
]}
personalData={[EmailField]}
/>
)}
</ConnectKitProvider>
Expand Down
Loading
Loading