From 4576c8c14a8b543e2090052b6a146adc8443ad3f Mon Sep 17 00:00:00 2001 From: giancarlo Date: Tue, 2 Apr 2024 14:09:25 +0800 Subject: [PATCH] Switch billing provider from Stripe to Lemon Squeezy Changed the billing provider in the `.env.development` file from Stripe to Lemon Squeezy. This requires adaptations at many levels: at the web app to load Lemon Squeezy's script in the checkout process, at the billing gateway to handle Lemon Squeezy calls, and in the database to reflect the current billing provider. The checkout process is now done using Lemon Squeezy Sessions and its billing strategy was adjusted accordingly. Billing-related components and services were also updated. --- apps/web/.env.development | 2 +- .../home/(user)/billing/return/page.tsx | 4 +- .../home/[account]/billing/return/page.tsx | 12 ++-- apps/web/public/locales/en/billing.json | 2 +- .../components/current-subscription-card.tsx | 72 +++++++++---------- .../src/components/embedded-checkout.tsx | 10 ++- .../lemon-squeezy-embedded-checkout.tsx | 34 ++++++--- .../services/create-lemon-squeezy-checkout.ts | 17 +---- .../lemon-squeezy-billing-strategy.service.ts | 7 +- .../lemon-squeezy-webhook-handler.service.ts | 12 +++- supabase/migrations/20221215192558_schema.sql | 26 ++++--- 11 files changed, 106 insertions(+), 92 deletions(-) diff --git a/apps/web/.env.development b/apps/web/.env.development index 18b8608d1..943f5375f 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -15,7 +15,7 @@ NEXT_PUBLIC_AUTH_PASSWORD=true NEXT_PUBLIC_AUTH_MAGIC_LINK=false # BILLING -NEXT_PUBLIC_BILLING_PROVIDER=stripe +NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy # SUPABASE NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 diff --git a/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx index 9f2927a16..af543ef5f 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx @@ -1,5 +1,5 @@ // We reuse the page from the billing module // as there is no need to create a new one. -import ReturnStripeSessionPage from '../../../[account]/billing/return/page'; +import ReturnCheckoutSessionPage from '../../../[account]/billing/return/page'; -export default ReturnStripeSessionPage; +export default ReturnCheckoutSessionPage; diff --git a/apps/web/app/(dashboard)/home/[account]/billing/return/page.tsx b/apps/web/app/(dashboard)/home/[account]/billing/return/page.tsx index ef2ec0768..fccface0f 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/return/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/return/page.tsx @@ -1,5 +1,5 @@ import dynamic from 'next/dynamic'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { BillingSessionStatus } from '@kit/billing-gateway/components'; @@ -30,9 +30,13 @@ const LazyEmbeddedCheckout = dynamic( ); async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) { - const { customerEmail, checkoutToken } = await loadCheckoutSession( - searchParams.session_id, - ); + const sessionId = searchParams.session_id; + + if (!sessionId) { + redirect('../'); + } + + const { customerEmail, checkoutToken } = await loadCheckoutSession(sessionId); if (checkoutToken) { return ( diff --git a/apps/web/public/locales/en/billing.json b/apps/web/public/locales/en/billing.json index db87ed061..81f2ae83e 100644 --- a/apps/web/public/locales/en/billing.json +++ b/apps/web/public/locales/en/billing.json @@ -65,7 +65,7 @@ "canceled": { "badge": "Canceled", "heading": "Your subscription is canceled", - "description": "Your subscription is canceled. It is scheduled to end on {{- endDate }}." + "description": "Your subscription is canceled. It is scheduled to end at end of the billing period." }, "unpaid": { "badge": "Unpaid", diff --git a/packages/billing/gateway/src/components/current-subscription-card.tsx b/packages/billing/gateway/src/components/current-subscription-card.tsx index d6fc0aefd..bc9449384 100644 --- a/packages/billing/gateway/src/components/current-subscription-card.tsx +++ b/packages/billing/gateway/src/components/current-subscription-card.tsx @@ -3,8 +3,6 @@ import { BadgeCheck } from 'lucide-react'; import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing'; import { Database } from '@kit/supabase/database'; - - import { Card, CardContent, @@ -91,48 +89,44 @@ export function CurrentSubscriptionCard({ -
- -
- - - - -
- - {subscription.trial_ends_at - ? formatDate(subscription.trial_ends_at, 'P') - : ''} - -
-
-
- - -
- - - - -
- - {formatDate(subscription.period_ends_at ?? '', 'P')} - -
-
-
- +
- + - +
+ + {subscription.trial_ends_at + ? formatDate(subscription.trial_ends_at, 'P') + : ''} + +
+
+ + +
+ + + + +
+ {formatDate(subscription.period_ends_at ?? '', 'P')} +
+
+
+ +
+ + + + +
diff --git a/packages/billing/gateway/src/components/embedded-checkout.tsx b/packages/billing/gateway/src/components/embedded-checkout.tsx index 02466b229..b14f0876a 100644 --- a/packages/billing/gateway/src/components/embedded-checkout.tsx +++ b/packages/billing/gateway/src/components/embedded-checkout.tsx @@ -40,7 +40,15 @@ function loadCheckoutComponent(provider: BillingProvider) { } case 'lemon-squeezy': { - throw new Error('Lemon Squeezy is not yet supported'); + return buildLazyComponent(() => { + return import('@kit/lemon-squeezy/components').then( + ({ LemonSqueezyEmbeddedCheckout }) => { + return { + default: LemonSqueezyEmbeddedCheckout, + }; + }, + ); + }); } case 'paddle': { diff --git a/packages/billing/lemon-squeezy/src/components/lemon-squeezy-embedded-checkout.tsx b/packages/billing/lemon-squeezy/src/components/lemon-squeezy-embedded-checkout.tsx index 877bd46eb..88d2e385d 100644 --- a/packages/billing/lemon-squeezy/src/components/lemon-squeezy-embedded-checkout.tsx +++ b/packages/billing/lemon-squeezy/src/components/lemon-squeezy-embedded-checkout.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { useEffect } from 'react'; + interface LemonSqueezyWindow extends Window { createLemonSqueezy: () => void; LemonSqueezy: { @@ -14,16 +18,24 @@ interface LemonSqueezyWindow extends Window { } export function LemonSqueezyEmbeddedCheckout(props: { checkoutToken: string }) { - return ( - - ); + return null; +} + +function useLoadScript(checkoutToken: string) { + useEffect(() => { + const script = document.createElement('script'); + + script.src = 'https://app.lemonsqueezy.com/js/lemon.js'; + + script.onload = () => { + const win = window as unknown as LemonSqueezyWindow; + + win.createLemonSqueezy(); + win.LemonSqueezy.Url.Open(checkoutToken); + }; + + document.body.appendChild(script); + }, [checkoutToken]); } diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index a553f634b..62b32c24c 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -29,12 +29,9 @@ export async function createLemonSqueezyCheckout( } const env = getLemonSqueezyEnv(); - const storeId = env.storeId; - const variantId = lineItem.id; - const urls = getUrls({ - returnUrl: params.returnUrl, - }); + const storeId = Number(env.storeId); + const variantId = Number(lineItem.id); const customer = params.customerId ? await getCustomer(params.customerId) @@ -63,7 +60,7 @@ export async function createLemonSqueezyCheckout( }, }, productOptions: { - redirectUrl: urls.return_url, + redirectUrl: params.returnUrl, }, expiresAt: null, preview: true, @@ -72,11 +69,3 @@ export async function createLemonSqueezyCheckout( return createCheckout(storeId, variantId, newCheckout); } - -function getUrls(params: { returnUrl: string }) { - const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`; - - return { - return_url: returnUrl, - }; -} 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 cdf5d8910..d898aacf5 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 @@ -32,7 +32,6 @@ export class LemonSqueezyBillingStrategyService accountId: params.accountId, returnUrl: params.returnUrl, trialDays: params.trialDays, - planId: params.plan.id, }, 'Creating checkout session...', ); @@ -40,6 +39,8 @@ export class LemonSqueezyBillingStrategyService const { data: response, error } = await createLemonSqueezyCheckout(params); if (error ?? !response?.data.id) { + console.log(error); + Logger.error( { name: 'billing.lemon-squeezy', @@ -62,7 +63,9 @@ export class LemonSqueezyBillingStrategyService 'Checkout session created successfully', ); - return { checkoutToken: response.data.id }; + return { + checkoutToken: response.data.attributes.url, + }; } async createBillingPortalSession( diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts index 5efe8443f..fd3b0e3e4 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts @@ -296,6 +296,8 @@ export class LemonSqueezyWebhookHandlerService return { id: item.id, quantity, + interval: params.interval, + interval_count: params.intervalCount, subscription_id: params.id, product_id: item.product, variant_id: item.variant, @@ -317,8 +319,12 @@ export class LemonSqueezyWebhookHandlerService cancel_at_period_end: params.cancelAtPeriodEnd ?? false, period_starts_at: getISOString(params.periodStartsAt) as string, period_ends_at: getISOString(params.periodEndsAt) as string, - trial_starts_at: getISOString(params.trialStartsAt), - trial_ends_at: getISOString(params.trialEndsAt), + trial_starts_at: params.trialStartsAt + ? getISOString(params.trialStartsAt) + : undefined, + trial_ends_at: params.trialEndsAt + ? getISOString(params.trialEndsAt) + : undefined, }; } @@ -360,7 +366,7 @@ export class LemonSqueezyWebhookHandlerService } function getISOString(date: number | null) { - return date ? new Date(date * 1000).toISOString() : undefined; + return date ? new Date(date).toISOString() : undefined; } function isSigningSecretValid(rawBody: Buffer, signatureHeader: string) { diff --git a/supabase/migrations/20221215192558_schema.sql b/supabase/migrations/20221215192558_schema.sql index ec571e24a..cdfc96d93 100644 --- a/supabase/migrations/20221215192558_schema.sql +++ b/supabase/migrations/20221215192558_schema.sql @@ -126,7 +126,8 @@ create type public.billing_provider as ENUM( create table if not exists public.config( enable_team_accounts boolean default true not null, enable_account_billing boolean default true not null, - enable_team_account_billing boolean default true not null + enable_team_account_billing boolean default true not null, + billing_provider public.billing_provider default 'stripe' not null ); comment on table public.config is 'Configuration for the Supabase MakerKit.'; @@ -137,6 +138,8 @@ comment on column public.config.enable_account_billing is 'Enable billing for in comment on column public.config.enable_team_account_billing is 'Enable billing for team accounts'; +comment on column public.config.billing_provider is 'The billing provider to use'; + alter table public.config enable row level security; -- create config row @@ -1101,8 +1104,6 @@ comment on column public.subscriptions.currency is 'The currency for the subscri comment on column public.subscriptions.status is 'The status of the subscription'; -comment on column public.subscriptions.type is 'The type of the subscription, either one-off or recurring'; - comment on column public.subscriptions.period_starts_at is 'The start of the current period for the subscription'; comment on column public.subscriptions.period_ends_at is 'The end of the current period for the subscription'; @@ -1128,8 +1129,8 @@ alter table public.subscriptions enable row level security; create policy subscriptions_read_self on public.subscriptions for select to authenticated using ( - (has_role_on_account(account_id) and config.is_set('enable_team_account_billing')) - or (account_id = auth.uid() and config.is_set('enable_account_billing')) + (has_role_on_account(account_id) and public.is_set('enable_team_account_billing')) + or (account_id = auth.uid() and public.is_set('enable_account_billing')) ); -- Functions @@ -1140,8 +1141,7 @@ create or replace function public.billing_provider, cancel_at_period_end bool, currency varchar(3), period_starts_at timestamptz, period_ends_at timestamptz, line_items jsonb, trial_starts_at timestamptz - default null, trial_ends_at timestamptz default null, type - public.subscription_type default 'recurring') + default null, trial_ends_at timestamptz default null) returns public.subscriptions as $$ declare @@ -1171,7 +1171,6 @@ on conflict ( id, active, status, - type, billing_provider, cancel_at_period_end, currency, @@ -1182,10 +1181,9 @@ on conflict ( values ( target_account_id, new_billing_customer_id, - subscription_id, + target_subscription_id, active, status, - type, billing_provider, cancel_at_period_end, currency, @@ -1226,7 +1224,7 @@ on conflict ( interval, interval_count) select - subscription_id, + target_subscription_id, prod_id, var_id, price_amt, @@ -1254,7 +1252,7 @@ language plpgsql; grant execute on function public.upsert_subscription(uuid, varchar, text, bool, public.subscription_status, public.billing_provider, bool, varchar, timestamptz, timestamptz, jsonb, timestamptz, - timestamptz, public.subscription_type) to service_role; + timestamptz) to service_role; /* ------------------------------------------------------- @@ -1356,8 +1354,8 @@ alter table public.orders enable row level security; -- account is their own create policy orders_read_self on public.orders for select to authenticated - using ((account_id = auth.uid() and config.is_set('enable_account_billing')) - or (has_role_on_account(account_id) and config.is_set('enable_team_account_billing'))); + using ((account_id = auth.uid() and public.is_set('enable_account_billing')) + or (has_role_on_account(account_id) and public.is_set('enable_team_account_billing'))); /**