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={} + /> + + + + + + + + + + + + + + + router.refresh()}> + + + + + + > + ); +} 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={} + /> + + + + + + + + + + + + + + + router.refresh()}> + + + + + + > + ); +} 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: