From d24cf8427fab728c4073503cd7335fb5401a7176 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Tue, 2 Apr 2024 12:19:09 +0800 Subject: [PATCH] Refactor billing system and enhance logging Updated the billing system's schema to change 'storeId' to a string type, and improved the cleanliness and readability of the code. Enhanced the logging system within the billing service for better tracking and debugging. In line with these changes, added corresponding error pages in the client side to handle any errors. --- .../(dashboard)/home/(user)/billing/error.tsx | 41 +++++ .../home/[account]/billing/error.tsx | 41 +++++ apps/web/config/app.config.ts | 73 +++++--- apps/web/config/billing.config.ts | 7 +- apps/web/public/locales/en/billing.json | 2 +- packages/billing/lemon-squeezy/README.md | 28 ++- .../schema/lemon-squeezy-server-env.schema.ts | 4 +- ...te-lemon-squeezy-billing-portal-session.ts | 13 +- .../lemon-squeezy-billing-strategy.service.ts | 165 ++++++++++++++++-- .../src/services/lemon-squeezy-sdk.ts | 2 +- .../stripe-billing-strategy.service.ts | 144 +++++++++++++-- packages/cms/core/package.json | 7 +- packages/cms/core/src/create-cms-client.ts | 4 +- pnpm-lock.yaml | 8 +- 14 files changed, 458 insertions(+), 81 deletions(-) create mode 100644 apps/web/app/(dashboard)/home/(user)/billing/error.tsx create mode 100644 apps/web/app/(dashboard)/home/[account]/billing/error.tsx diff --git a/apps/web/app/(dashboard)/home/(user)/billing/error.tsx b/apps/web/app/(dashboard)/home/(user)/billing/error.tsx new file mode 100644 index 000000000..7fa5431a1 --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/billing/error.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; +import { Button } from '@kit/ui/button'; +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +export default function BillingErrorPage() { + const router = useRouter(); + + return ( + <> + } + description={} + /> + + +
+ + + + + + + + + + +
+ +
+
+
+ + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/billing/error.tsx b/apps/web/app/(dashboard)/home/[account]/billing/error.tsx new file mode 100644 index 000000000..7fa5431a1 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/billing/error.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; +import { Button } from '@kit/ui/button'; +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +export default function BillingErrorPage() { + const router = useRouter(); + + return ( + <> + } + description={} + /> + + +
+ + + + + + + + + + +
+ +
+
+
+ + ); +} diff --git a/apps/web/config/app.config.ts b/apps/web/config/app.config.ts index 76dd292a3..66c9ec594 100644 --- a/apps/web/config/app.config.ts +++ b/apps/web/config/app.config.ts @@ -2,33 +2,52 @@ import { z } from 'zod'; const production = process.env.NODE_ENV === 'production'; -const AppConfigSchema = z.object({ - name: z - .string({ - description: `This is the name of your SaaS. Ex. "Makerkit"`, - }) - .min(1), - title: z - .string({ - description: `This is the default title tag of your SaaS.`, - }) - .min(1), - description: z.string({ - description: `This is the default description of your SaaS.`, - }), - url: z.string().url({ - message: `Please provide a valid URL. Example: 'https://example.com'`, - }), - locale: z - .string({ - description: `This is the default locale of your SaaS.`, - }) - .default('en'), - theme: z.enum(['light', 'dark', 'system']), - production: z.boolean(), - themeColor: z.string(), - themeColorDark: z.string(), -}); +const AppConfigSchema = z + .object({ + name: z + .string({ + description: `This is the name of your SaaS. Ex. "Makerkit"`, + }) + .min(1), + title: z + .string({ + description: `This is the default title tag of your SaaS.`, + }) + .min(1), + description: z.string({ + description: `This is the default description of your SaaS.`, + }), + url: z.string().url({ + message: `Please provide a valid URL. Example: 'https://example.com'`, + }), + locale: z + .string({ + description: `This is the default locale of your SaaS.`, + }) + .default('en'), + theme: z.enum(['light', 'dark', 'system']), + production: z.boolean(), + themeColor: z.string(), + themeColorDark: z.string(), + }) + .refine( + (schema) => { + return !(schema.production && schema.url.startsWith('http:')); + }, + { + message: `Please use a valid HTTPS URL in production.`, + path: ['url'], + }, + ) + .refine( + (schema) => { + return schema.themeColor !== schema.themeColorDark; + }, + { + message: `Please provide different theme colors for light and dark themes.`, + path: ['themeColor'], + }, + ); const appConfig = AppConfigSchema.parse({ name: process.env.NEXT_PUBLIC_PRODUCT_NAME, diff --git a/apps/web/config/billing.config.ts b/apps/web/config/billing.config.ts index e75a8e85b..c3009ca5c 100644 --- a/apps/web/config/billing.config.ts +++ b/apps/web/config/billing.config.ts @@ -1,11 +1,16 @@ import { BillingProviderSchema, createBillingSchema } from '@kit/billing'; +// The billing provider to use. This should be set in the environment variables +// and should match the provider in the database. We also add it here so we can validate +// your configuration against the selected provider at build time. const provider = BillingProviderSchema.parse( process.env.NEXT_PUBLIC_BILLING_PROVIDER, ); export default createBillingSchema({ + // also update config.billing_provider in the DB to match the selected provider, + // products configuration products: [ { id: 'lifetime', @@ -45,7 +50,7 @@ export default createBillingSchema({ interval: 'month', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + id: '55476', name: 'Base', description: 'Base plan', cost: 9.99, diff --git a/apps/web/public/locales/en/billing.json b/apps/web/public/locales/en/billing.json index 69b53d81d..db87ed061 100644 --- a/apps/web/public/locales/en/billing.json +++ b/apps/web/public/locales/en/billing.json @@ -39,7 +39,7 @@ "planPickerLabel": "Pick your preferred plan", "planCardLabel": "Manage your Plan", "planPickerAlertErrorTitle": "Error requesting checkout", - "planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.", + "planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.", "cancelSubscriptionDate": "Your subscription will be cancelled at the end of the period", "status": { "free": { diff --git a/packages/billing/lemon-squeezy/README.md b/packages/billing/lemon-squeezy/README.md index 7fdd84632..5f31d9ec1 100644 --- a/packages/billing/lemon-squeezy/README.md +++ b/packages/billing/lemon-squeezy/README.md @@ -6,8 +6,32 @@ Please add the following environment variables to your `.env.local` file during ```env LEMON_SQUEEZY_SECRET_KEY= -LEMON_SQUEEZY_WEBHOOK_SECRET= +LEMON_SQUEEZY_SIGNING_SECRET= LEMON_SQUEEZY_STORE_ID= ``` -Add the variables to your production environment as well using your CI. \ No newline at end of file +Add the variables to your production environment as well using your CI. + +### Webhooks + +When testing locally, you are required to set up a proxy to your own local server, so you can receive the webhooks from Lemon Squeezy. You can use [ngrok](https://ngrok.com/) for this purpose, or any other similar service (LocalTunnel, Cloudflare Tunnel, Localcan, etc). + +Once you have the proxy running, you can add the URL to your Lemon Squeezy account developer account as the Webhooks URL. + +Please set your app configuration URL to the following: + +``` +NEXT_PUBLIC_SITE_URL=https:// +``` + +Replace `` with the URL provided by the proxy service. + +#### Webhook Events + +You must point the webhook to the `/api/billing/webhook` endpoint in your local server. + +Please subscribe to the following events: +- `order_created` +- `subscription_created` +- `subscription_updated` +- `subscription_expired` \ No newline at end of file diff --git a/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts b/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts index 3e9440a3e..ed3710c53 100644 --- a/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts +++ b/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts @@ -5,10 +5,10 @@ export const getLemonSqueezyEnv = () => .object({ secretKey: z.string().min(1), webhooksSecret: z.string().min(1), - storeId: z.number().positive(), + storeId: z.string(), }) .parse({ secretKey: process.env.LEMON_SQUEEZY_SECRET_KEY, - webhooksSecret: process.env.LEMON_SQUEEZY_WEBHOOK_SECRET, + webhooksSecret: process.env.LEMON_SQUEEZY_SIGNING_SECRET, storeId: process.env.LEMON_SQUEEZY_STORE_ID, }); diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts index 026f06c9a..c118c73e6 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts @@ -9,19 +9,16 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk'; * Creates a LemonSqueezy billing portal session for the given parameters. * * @param {object} params - The parameters required to create the billing portal session. - * @return {Promise} - A promise that resolves to the URL of the customer portal. - * @throws {Error} - If no customer is found with the given customerId. */ export async function createLemonSqueezyBillingPortalSession( params: z.infer, ) { await initializeLemonSqueezyClient(); - const customer = await getCustomer(params.customerId); + const { data, error } = await getCustomer(params.customerId); - if (!customer?.data) { - throw new Error('No customer found'); - } - - return customer.data.data.attributes.urls.customer_portal; + return { + data: data?.data.attributes.urls.customer_portal, + error, + }; } diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts index ca0f4bd33..cdf5d8910 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts @@ -14,6 +14,7 @@ import { ReportBillingUsageSchema, RetrieveCheckoutSessionSchema, } from '@kit/billing/schema'; +import { Logger } from '@kit/shared/logger'; import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session'; import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout'; @@ -24,66 +25,206 @@ export class LemonSqueezyBillingStrategyService async createCheckoutSession( params: z.infer, ) { - const { data: response } = await createLemonSqueezyCheckout(params); + Logger.info( + { + name: 'billing.lemon-squeezy', + customerId: params.customerId, + accountId: params.accountId, + returnUrl: params.returnUrl, + trialDays: params.trialDays, + planId: params.plan.id, + }, + 'Creating checkout session...', + ); + + const { data: response, error } = await createLemonSqueezyCheckout(params); + + if (error ?? !response?.data.id) { + Logger.error( + { + name: 'billing.lemon-squeezy', + customerId: params.customerId, + accountId: params.accountId, + error: error?.message, + }, + 'Failed to create checkout session', + ); - if (!response?.data.id) { throw new Error('Failed to create checkout session'); } + Logger.info( + { + name: 'billing.lemon-squeezy', + customerId: params.customerId, + accountId: params.accountId, + }, + 'Checkout session created successfully', + ); + return { checkoutToken: response.data.id }; } async createBillingPortalSession( params: z.infer, ) { - const url = await createLemonSqueezyBillingPortalSession(params); + Logger.info( + { + name: 'billing.lemon-squeezy', + customerId: params.customerId, + }, + 'Creating billing portal session...', + ); + + const { data, error } = + await createLemonSqueezyBillingPortalSession(params); + + if (error ?? !data) { + Logger.error( + { + name: 'billing.lemon-squeezy', + customerId: params.customerId, + error: error?.message, + }, + 'Failed to create billing portal session', + ); - if (!url) { throw new Error('Failed to create billing portal session'); } - return { url }; + Logger.info( + { + name: 'billing.lemon-squeezy', + customerId: params.customerId, + }, + 'Billing portal session created successfully', + ); + + return { url: data }; } async cancelSubscription( params: z.infer, ) { - await cancelSubscription(params.subscriptionId); + Logger.info( + { + name: 'billing.lemon-squeezy', + subscriptionId: params.subscriptionId, + }, + 'Cancelling subscription...', + ); - return { success: true }; + try { + const { error } = await cancelSubscription(params.subscriptionId); + + if (error) { + throw error; + } + + Logger.info( + { + name: 'billing.lemon-squeezy', + subscriptionId: params.subscriptionId, + }, + 'Subscription cancelled successfully', + ); + + return { success: true }; + } catch (error) { + Logger.error( + { + name: 'billing.lemon-squeezy', + subscriptionId: params.subscriptionId, + error: (error as Error)?.message, + }, + 'Failed to cancel subscription', + ); + + throw new Error('Failed to cancel subscription'); + } } async retrieveCheckoutSession( params: z.infer, ) { - const session = await getCheckout(params.sessionId); + Logger.info( + { + name: 'billing.lemon-squeezy', + sessionId: params.sessionId, + }, + 'Retrieving checkout session...', + ); + + const { data: session, error } = await getCheckout(params.sessionId); + + if (error ?? !session?.data) { + Logger.error( + { + name: 'billing.lemon-squeezy', + sessionId: params.sessionId, + error: error?.message, + }, + 'Failed to retrieve checkout session', + ); - if (!session.data) { throw new Error('Failed to retrieve checkout session'); } - const data = session.data.data; + Logger.info( + { + name: 'billing.lemon-squeezy', + sessionId: params.sessionId, + }, + 'Checkout session retrieved successfully', + ); + + const { id, attributes } = session.data; return { - checkoutToken: data.id, + checkoutToken: id, isSessionOpen: false, status: 'complete' as const, customer: { - email: data.attributes.checkout_data.email, + email: attributes.checkout_data.email, }, }; } async reportUsage(params: z.infer) { + Logger.info( + { + name: 'billing.lemon-squeezy', + subscriptionItemId: params.subscriptionId, + }, + 'Reporting usage...', + ); + const { error } = await createUsageRecord({ quantity: params.usage.quantity, subscriptionItemId: params.subscriptionId, }); if (error) { + Logger.error( + { + name: 'billing.lemon-squeezy', + subscriptionItemId: params.subscriptionId, + error: error.message, + }, + 'Failed to report usage', + ); + throw new Error('Failed to report usage'); } + Logger.info( + { + name: 'billing.lemon-squeezy', + subscriptionItemId: params.subscriptionId, + }, + 'Usage reported successfully', + ); + return { success: true }; } } diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts index e499c7c92..ced371d5e 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts @@ -19,7 +19,7 @@ export async function initializeLemonSqueezyClient() { name: `billing.lemon-squeezy`, error: error.message, }, - 'Error in Lemon Squeezy SDK', + 'Encountered an error using the Lemon Squeezy SDK', ); }, }); diff --git a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts index 77ec379bd..dbb497a65 100644 --- a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts +++ b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts @@ -10,6 +10,7 @@ import { ReportBillingUsageSchema, RetrieveCheckoutSessionSchema, } from '@kit/billing/schema'; +import { Logger } from '@kit/shared/logger'; import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session'; import { createStripeCheckout } from './create-stripe-checkout'; @@ -23,12 +24,39 @@ export class StripeBillingStrategyService ) { const stripe = await this.stripeProvider(); + Logger.info( + { + name: 'billing.stripe', + customerId: params.customerId, + accountId: params.accountId, + }, + 'Creating checkout session...', + ); + const { client_secret } = await createStripeCheckout(stripe, params); if (!client_secret) { + Logger.error( + { + name: 'billing.stripe', + customerId: params.customerId, + accountId: params.accountId, + }, + 'Failed to create checkout session', + ); + throw new Error('Failed to create checkout session'); } + Logger.info( + { + name: 'billing.stripe', + customerId: params.customerId, + accountId: params.accountId, + }, + 'Checkout session created successfully', + ); + return { checkoutToken: client_secret }; } @@ -37,7 +65,35 @@ export class StripeBillingStrategyService ) { const stripe = await this.stripeProvider(); - return createStripeBillingPortalSession(stripe, params); + Logger.info( + { + name: 'billing.stripe', + customerId: params.customerId, + }, + 'Creating billing portal session...', + ); + + const session = await createStripeBillingPortalSession(stripe, params); + + if (!session?.url) { + Logger.error( + { + name: 'billing.stripe', + customerId: params.customerId, + }, + 'Failed to create billing portal session', + ); + } else { + Logger.info( + { + name: 'billing.stripe', + customerId: params.customerId, + }, + 'Billing portal session created successfully', + ); + } + + return session; } async cancelSubscription( @@ -45,11 +101,40 @@ export class StripeBillingStrategyService ) { const stripe = await this.stripeProvider(); - await stripe.subscriptions.cancel(params.subscriptionId, { - invoice_now: params.invoiceNow ?? true, - }); + Logger.info( + { + name: 'billing.stripe', + subscriptionId: params.subscriptionId, + }, + 'Cancelling subscription...', + ); - return { success: true }; + try { + await stripe.subscriptions.cancel(params.subscriptionId, { + invoice_now: params.invoiceNow ?? true, + }); + + Logger.info( + { + name: 'billing.stripe', + subscriptionId: params.subscriptionId, + }, + 'Subscription cancelled successfully', + ); + + return { success: true }; + } catch (e) { + Logger.error( + { + name: 'billing.stripe', + subscriptionId: params.subscriptionId, + error: e, + }, + 'Failed to cancel subscription', + ); + + throw new Error('Failed to cancel subscription'); + } } async retrieveCheckoutSession( @@ -57,17 +142,46 @@ export class StripeBillingStrategyService ) { const stripe = await this.stripeProvider(); - const session = await stripe.checkout.sessions.retrieve(params.sessionId); - const isSessionOpen = session.status === 'open'; - - return { - checkoutToken: session.client_secret, - isSessionOpen, - status: session.status ?? 'complete', - customer: { - email: session.customer_details?.email ?? null, + Logger.info( + { + name: 'billing.stripe', + sessionId: params.sessionId, }, - }; + 'Retrieving checkout session...', + ); + + try { + const session = await stripe.checkout.sessions.retrieve(params.sessionId); + const isSessionOpen = session.status === 'open'; + + Logger.info( + { + name: 'billing.stripe', + sessionId: params.sessionId, + }, + 'Checkout session retrieved successfully', + ); + + return { + checkoutToken: session.client_secret, + isSessionOpen, + status: session.status ?? 'complete', + customer: { + email: session.customer_details?.email ?? null, + }, + }; + } catch (error) { + Logger.error( + { + name: 'billing.stripe', + sessionId: params.sessionId, + error, + }, + 'Failed to retrieve checkout session', + ); + + throw new Error('Failed to retrieve checkout session'); + } } async reportUsage(params: z.infer) { diff --git a/packages/cms/core/package.json b/packages/cms/core/package.json index 207a1fa28..5606799a8 100644 --- a/packages/cms/core/package.json +++ b/packages/cms/core/package.json @@ -13,15 +13,12 @@ ".": "./src/index.ts" }, "peerDependencies": { - "@kit/contentlayer": "workspace:*", - "@kit/wordpress": "workspace:*" + "@kit/contentlayer": "workspace:*" }, "devDependencies": { - "@kit/contentlayer": "*", "@kit/eslint-config": "workspace:*", "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/wordpress": "*" + "@kit/tsconfig": "workspace:*" }, "eslintConfig": { "root": true, diff --git a/packages/cms/core/src/create-cms-client.ts b/packages/cms/core/src/create-cms-client.ts index 52cf7417c..3c3f15b84 100644 --- a/packages/cms/core/src/create-cms-client.ts +++ b/packages/cms/core/src/create-cms-client.ts @@ -24,13 +24,13 @@ export async function createCmsClient( } async function getContentLayerClient() { - const { ContentlayerClient } = await import('@kit/contentlayer'); + const { ContentlayerClient } = await import('../../contentlayer'); return new ContentlayerClient(); } async function getWordpressClient() { - const { WordpressClient } = await import('@kit/wordpress'); + const { WordpressClient } = await import('../../wordpress'); return new WordpressClient(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf576119a..f97d10e78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,10 +359,11 @@ importers: version: link:../../ui packages/cms/core: - devDependencies: + dependencies: '@kit/contentlayer': - specifier: '*' + specifier: workspace:* version: link:../contentlayer + devDependencies: '@kit/eslint-config': specifier: workspace:* version: link:../../../tooling/eslint @@ -372,9 +373,6 @@ importers: '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript - '@kit/wordpress': - specifier: '*' - version: link:../wordpress packages/cms/wordpress: devDependencies: