Skip to content

Commit

Permalink
Add pricing table and use global configuration Customer on Stripe (#12)
Browse files Browse the repository at this point in the history
* ensure configuration customer on Stripe and use its theme

* add typed backend client to frontend

* implement pricing page into frontend

* lint & format
  • Loading branch information
kasparkallas authored Oct 27, 2023
1 parent 2196e6f commit 3d66bd9
Show file tree
Hide file tree
Showing 17 changed files with 680 additions and 146 deletions.
2 changes: 1 addition & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"clean": "npm-run-all -s clean:*",
"clean:dist": "rimraf \"./dist\"",
"clean:node-modules": "rimraf \"./node_modules\"",
"generate:accounting-client": "pnpm exec openapi-typescript https://accounting.superfluid.dev/static/api-docs.yaml -o ./src/super-token-accounting/client/types.d.ts"
"generate:accounting-openapi-client": "pnpm exec openapi-typescript https://accounting.superfluid.dev/static/api-docs.yaml -o ./src/super-token-accounting/client/types.d.ts"
},
"jest": {
"collectCoverageFrom": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class CheckoutSessionController {
await this.queue.add(CHECKOUT_SESSION_JOB_NAME, validationResult.data, {
jobId: jobId,
// Remove finished job ASAP in case a new fresh job is triggered.
removeOnComplete: true,
removeOnComplete: false,
removeOnFail: true,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { Controller, Get, Logger, Query } from '@nestjs/common';
import { WidgetProps } from '@superfluid-finance/widget';
import Stripe from 'stripe';
import { SuperfluidStripeConverterService } from './superfluid-stripe-converter.service';
import { DEFAULT_PAGING } from 'src/stripe-module-config';

type Response = {
type ProductResponse = {
stripeProduct: Stripe.Product;
productDetails: WidgetProps['productDetails'];
paymentDetails: WidgetProps['paymentDetails'];
};
Expand All @@ -17,31 +19,64 @@ export class SuperfluidStripeConverterController {
) {}

// TODO: Does this need auth?
@Get('checkout-widget')
@Get('product')
async mapStripeProductToCheckoutWidget(
@Query('product-id') productId: string,
): Promise<Response> {
const [stripeProductsResponse, stripePricesResponse] = await Promise.all([
): Promise<ProductResponse> {
const [stripeProduct, stripePrices, configurationCustomer] = await Promise.all([
this.stripeClient.products.retrieve(productId),
this.stripeClient.prices.list({
product: productId,
active: true,
}),
this.stripeClient.prices
.list({
product: productId,
active: true,
})
.autoPagingToArray(DEFAULT_PAGING),
this.superfluidStripeConverterService.ensureConfigurationCustomer(),
]);

const config = this.superfluidStripeConverterService.mapStripeProductToWidgetConfig({
product: stripeProductsResponse,
prices: stripePricesResponse.data,
// check eligibility somewhere?

const config = await this.superfluidStripeConverterService.mapStripeProductToWidgetConfig({
configurationCustomer,
product: stripeProduct,
prices: stripePrices,
});

// logger.debug({
// stripeProductsResponse,
// stripePricesResponse,
// productId,
// config,
// });
return { ...config, stripeProduct: stripeProduct };
}

@Get('products')
async products(): Promise<ProductResponse[]> {
const [stripeProducts, stripePrices, configurationCustomer] = await Promise.all([
this.stripeClient.products
.list({
active: true,
})
.autoPagingToArray(DEFAULT_PAGING),
this.stripeClient.prices
.list({
active: true,
})
.autoPagingToArray(DEFAULT_PAGING),
this.superfluidStripeConverterService.ensureConfigurationCustomer(),
]);

// check eligibility somewhere?

const results = await Promise.all(
stripeProducts.map(async (stripeProduct) => {
const pricesForProduct = stripePrices.filter((price) => price.product === stripeProduct.id);

const config = await this.superfluidStripeConverterService.mapStripeProductToWidgetConfig({
product: stripeProduct,
prices: pricesForProduct,
});

return { ...config, stripeProduct };
}),
);

return config;
return results;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,25 @@ import {
import { ChainId, PaymentOption, ProductDetails, WidgetProps } from '@superfluid-finance/widget';
import { currencyDecimalMapping } from 'src/stripe-currencies';
import { formatUnits } from 'viem';
import { InjectStripeClient } from '@golevelup/nestjs-stripe';
import { DEFAULT_PAGING } from 'src/stripe-module-config';

const configurationCustomerEmail = '[email protected]' as const;

type Input = {
product: Stripe.Product;
prices: Stripe.Price[]; // NOTE: These need to be fetched separately from the Product based on Product ID.
configurationCustomer?: Stripe.Customer;
};

type Output = {
productDetails: ProductDetails;
paymentDetails: WidgetProps['paymentDetails'];
theme: any; // TODO: get rid of any
};

interface StripeProductToWidgetConfigMapper {
mapStripeProductToWidgetConfig(stripe: Input): Output;
mapStripeProductToWidgetConfig(stripe: Input): Promise<Output>;
}

type PriceId = string;
Expand All @@ -34,14 +40,55 @@ interface SuperTokenToStripeCurrencyMapper {
}): PriceId | undefined;
}

// Rename to "global config"?
interface ConfigurationCustomerManager {
ensureConfigurationCustomer(): Promise<Stripe.Customer>;
}

@Injectable()
export class SuperfluidStripeConverterService
implements StripeProductToWidgetConfigMapper, SuperTokenToStripeCurrencyMapper
implements
StripeProductToWidgetConfigMapper,
SuperTokenToStripeCurrencyMapper,
ConfigurationCustomerManager
{
// TODO(KK): Inject
private readonly chainToSuperTokenReceiverMap = defaultChainToSuperTokenReceiverMap;
private readonly stripeCurrencyToSuperTokenMap = defaultStripeCurrencyToSuperTokenMap;

constructor(@InjectStripeClient() private readonly stripeClient: Stripe) {}

async ensureConfigurationCustomer(): Promise<Stripe.Customer> {
// TODO: caching
// TODO: use better constants

let configurationCustomer: Stripe.Customer;

const customers = await this.stripeClient.customers
.list({
email: configurationCustomerEmail,
})
.autoPagingToArray(DEFAULT_PAGING);

if (customers.length === 1) {
configurationCustomer = customers[0];
} else if (customers.length === 0) {
configurationCustomer = await this.stripeClient.customers.create({
email: configurationCustomerEmail,
metadata: {
note: 'Auto-generated. Be careful when editing!',
theme: `{"palette":{"mode":"light","primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}}`,
},
});
} else {
throw new Error(
`There should not be more than one Superfluid-Stripe configuration customer. Please remove one of the customers with e-mail: [${configurationCustomerEmail}]`,
);
}

return configurationCustomer;
}

mapSuperTokenToStripeCurrency(superToken: {
chainId: number;
address: string;
Expand All @@ -60,9 +107,12 @@ export class SuperfluidStripeConverterService
return undefined;
}

mapStripeProductToWidgetConfig(stripe: Input): Output {
async mapStripeProductToWidgetConfig(stripe: Input): Promise<Output> {
// TODO(KK): Enforce it's a subscription-based product?

const configurationCustomer =
stripe.configurationCustomer ?? (await this.ensureConfigurationCustomer());

const productDetails: Output['productDetails'] = {
name: stripe.product.name,
description: stripe.product.description ?? '', // TODO(KK): Stripe product might not have a description. The Product Card of the widget should still look good.
Expand Down Expand Up @@ -112,9 +162,22 @@ export class SuperfluidStripeConverterService
paymentOptions,
};

// TODO: use Zod for validation?
// TODO: get rid of any
let theme: any;
try {
theme = JSON.parse(configurationCustomer.metadata['theme']);
} catch (e) {
logger.error(e);
}

logger.debug('theme');
logger.debug(theme);

return {
productDetails,
paymentDetails,
theme,
};
}
}
Expand Down
6 changes: 5 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"clean:node-modules": "rimraf \"./node_modules\"",
"docker": "pnpm docker:build && pnpm docker:run",
"docker:build": "docker build -t superfluid-finance/stripe-frontend .",
"docker:run": "docker run -d -p 8000:8000 superfluid-finance/stripe-frontend"
"docker:run": "docker run -d -p 8000:8000 superfluid-finance/stripe-frontend",
"generate:backend-openapi-client": "pnpm exec openapi-typescript http://localhost:3001/swagger-json -o ./src/backend-openapi-client.d.ts"
},
"dependencies": {
"@emotion/cache": "latest",
Expand All @@ -29,8 +30,11 @@
"clsx": "^2.0.0",
"connectkit": "^1.5.3",
"next": "latest",
"openapi-fetch": "^0.8.1",
"openapi-typescript": "^6.7.0",
"react": "latest",
"react-dom": "latest",
"stripe": "^14.1.0",
"viem": "^1.17.1",
"wagmi": "^1.4.5",
"zod": "^3.22.4"
Expand Down
Loading

0 comments on commit 3d66bd9

Please sign in to comment.