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.
This commit is contained in:
giancarlo
2024-04-02 14:09:25 +08:00
parent d92d224250
commit 4576c8c14a
11 changed files with 106 additions and 92 deletions

View File

@@ -15,7 +15,7 @@ NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false
# BILLING # BILLING
NEXT_PUBLIC_BILLING_PROVIDER=stripe NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy
# SUPABASE # SUPABASE
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321

View File

@@ -1,5 +1,5 @@
// We reuse the page from the billing module // We reuse the page from the billing module
// as there is no need to create a new one. // 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;

View File

@@ -1,5 +1,5 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { notFound } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { BillingSessionStatus } from '@kit/billing-gateway/components'; import { BillingSessionStatus } from '@kit/billing-gateway/components';
@@ -30,9 +30,13 @@ const LazyEmbeddedCheckout = dynamic(
); );
async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) { async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
const { customerEmail, checkoutToken } = await loadCheckoutSession( const sessionId = searchParams.session_id;
searchParams.session_id,
); if (!sessionId) {
redirect('../');
}
const { customerEmail, checkoutToken } = await loadCheckoutSession(sessionId);
if (checkoutToken) { if (checkoutToken) {
return ( return (

View File

@@ -65,7 +65,7 @@
"canceled": { "canceled": {
"badge": "Canceled", "badge": "Canceled",
"heading": "Your subscription is 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": { "unpaid": {
"badge": "Unpaid", "badge": "Unpaid",

View File

@@ -3,8 +3,6 @@ import { BadgeCheck } from 'lucide-react';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing'; import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { import {
Card, Card,
CardContent, CardContent,
@@ -91,48 +89,44 @@ export function CurrentSubscriptionCard({
</div> </div>
</If> </If>
<div> <If condition={subscription.status === 'trialing'}>
<If condition={subscription.status === 'trialing'}>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:trialEndsOn" />
</span>
<div className={'text-muted-foreground'}>
<span>
{subscription.trial_ends_at
? formatDate(subscription.trial_ends_at, 'P')
: ''}
</span>
</div>
</div>
</If>
<If condition={subscription.cancel_at_period_end}>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:cancelSubscriptionDate" />
</span>
<div className={'text-muted-foreground'}>
<span>
{formatDate(subscription.period_ends_at ?? '', 'P')}
</span>
</div>
</div>
</If>
<div className="flex flex-col space-y-0.5"> <div className="flex flex-col space-y-0.5">
<span className="font-semibold"> <span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" /> <Trans i18nKey="billing:trialEndsOn" />
</span> </span>
<LineItemDetails <div className={'text-muted-foreground'}>
lineItems={productLineItems} <span>
currency={subscription.currency} {subscription.trial_ends_at
selectedInterval={firstLineItem.interval} ? formatDate(subscription.trial_ends_at, 'P')
/> : ''}
</span>
</div>
</div> </div>
</If>
<If condition={subscription.cancel_at_period_end}>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:cancelSubscriptionDate" />
</span>
<div className={'text-muted-foreground'}>
<span>{formatDate(subscription.period_ends_at ?? '', 'P')}</span>
</div>
</div>
</If>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
</span>
<LineItemDetails
lineItems={productLineItems}
currency={subscription.currency}
selectedInterval={firstLineItem.interval}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -40,7 +40,15 @@ function loadCheckoutComponent(provider: BillingProvider) {
} }
case 'lemon-squeezy': { 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': { case 'paddle': {

View File

@@ -1,3 +1,7 @@
'use client';
import { useEffect } from 'react';
interface LemonSqueezyWindow extends Window { interface LemonSqueezyWindow extends Window {
createLemonSqueezy: () => void; createLemonSqueezy: () => void;
LemonSqueezy: { LemonSqueezy: {
@@ -14,16 +18,24 @@ interface LemonSqueezyWindow extends Window {
} }
export function LemonSqueezyEmbeddedCheckout(props: { checkoutToken: string }) { export function LemonSqueezyEmbeddedCheckout(props: { checkoutToken: string }) {
return ( useLoadScript(props.checkoutToken);
<script
src="https://app.lemonsqueezy.com/js/lemon.js"
defer
onLoad={() => {
const win = window as unknown as LemonSqueezyWindow;
win.createLemonSqueezy(); return null;
win.LemonSqueezy.Url.Open(props.checkoutToken); }
}}
></script> 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]);
} }

View File

@@ -29,12 +29,9 @@ export async function createLemonSqueezyCheckout(
} }
const env = getLemonSqueezyEnv(); const env = getLemonSqueezyEnv();
const storeId = env.storeId;
const variantId = lineItem.id;
const urls = getUrls({ const storeId = Number(env.storeId);
returnUrl: params.returnUrl, const variantId = Number(lineItem.id);
});
const customer = params.customerId const customer = params.customerId
? await getCustomer(params.customerId) ? await getCustomer(params.customerId)
@@ -63,7 +60,7 @@ export async function createLemonSqueezyCheckout(
}, },
}, },
productOptions: { productOptions: {
redirectUrl: urls.return_url, redirectUrl: params.returnUrl,
}, },
expiresAt: null, expiresAt: null,
preview: true, preview: true,
@@ -72,11 +69,3 @@ export async function createLemonSqueezyCheckout(
return createCheckout(storeId, variantId, newCheckout); return createCheckout(storeId, variantId, newCheckout);
} }
function getUrls(params: { returnUrl: string }) {
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
return {
return_url: returnUrl,
};
}

View File

@@ -32,7 +32,6 @@ export class LemonSqueezyBillingStrategyService
accountId: params.accountId, accountId: params.accountId,
returnUrl: params.returnUrl, returnUrl: params.returnUrl,
trialDays: params.trialDays, trialDays: params.trialDays,
planId: params.plan.id,
}, },
'Creating checkout session...', 'Creating checkout session...',
); );
@@ -40,6 +39,8 @@ export class LemonSqueezyBillingStrategyService
const { data: response, error } = await createLemonSqueezyCheckout(params); const { data: response, error } = await createLemonSqueezyCheckout(params);
if (error ?? !response?.data.id) { if (error ?? !response?.data.id) {
console.log(error);
Logger.error( Logger.error(
{ {
name: 'billing.lemon-squeezy', name: 'billing.lemon-squeezy',
@@ -62,7 +63,9 @@ export class LemonSqueezyBillingStrategyService
'Checkout session created successfully', 'Checkout session created successfully',
); );
return { checkoutToken: response.data.id }; return {
checkoutToken: response.data.attributes.url,
};
} }
async createBillingPortalSession( async createBillingPortalSession(

View File

@@ -296,6 +296,8 @@ export class LemonSqueezyWebhookHandlerService
return { return {
id: item.id, id: item.id,
quantity, quantity,
interval: params.interval,
interval_count: params.intervalCount,
subscription_id: params.id, subscription_id: params.id,
product_id: item.product, product_id: item.product,
variant_id: item.variant, variant_id: item.variant,
@@ -317,8 +319,12 @@ export class LemonSqueezyWebhookHandlerService
cancel_at_period_end: params.cancelAtPeriodEnd ?? false, cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string, period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string, period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: getISOString(params.trialStartsAt), trial_starts_at: params.trialStartsAt
trial_ends_at: getISOString(params.trialEndsAt), ? 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) { 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) { function isSigningSecretValid(rawBody: Buffer, signatureHeader: string) {

View File

@@ -126,7 +126,8 @@ create type public.billing_provider as ENUM(
create table if not exists public.config( create table if not exists public.config(
enable_team_accounts boolean default true not null, enable_team_accounts boolean default true not null,
enable_account_billing 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.'; 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.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; alter table public.config enable row level security;
-- create config row -- 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.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_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'; 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 create policy subscriptions_read_self on public.subscriptions
for select to authenticated for select to authenticated
using ( using (
(has_role_on_account(account_id) and config.is_set('enable_team_account_billing')) (has_role_on_account(account_id) and public.is_set('enable_team_account_billing'))
or (account_id = auth.uid() and config.is_set('enable_account_billing')) or (account_id = auth.uid() and public.is_set('enable_account_billing'))
); );
-- Functions -- Functions
@@ -1140,8 +1141,7 @@ create or replace function
public.billing_provider, cancel_at_period_end bool, currency public.billing_provider, cancel_at_period_end bool, currency
varchar(3), period_starts_at timestamptz, period_ends_at varchar(3), period_starts_at timestamptz, period_ends_at
timestamptz, line_items jsonb, trial_starts_at timestamptz timestamptz, line_items jsonb, trial_starts_at timestamptz
default null, trial_ends_at timestamptz default null, type default null, trial_ends_at timestamptz default null)
public.subscription_type default 'recurring')
returns public.subscriptions returns public.subscriptions
as $$ as $$
declare declare
@@ -1171,7 +1171,6 @@ on conflict (
id, id,
active, active,
status, status,
type,
billing_provider, billing_provider,
cancel_at_period_end, cancel_at_period_end,
currency, currency,
@@ -1182,10 +1181,9 @@ on conflict (
values ( values (
target_account_id, target_account_id,
new_billing_customer_id, new_billing_customer_id,
subscription_id, target_subscription_id,
active, active,
status, status,
type,
billing_provider, billing_provider,
cancel_at_period_end, cancel_at_period_end,
currency, currency,
@@ -1226,7 +1224,7 @@ on conflict (
interval, interval,
interval_count) interval_count)
select select
subscription_id, target_subscription_id,
prod_id, prod_id,
var_id, var_id,
price_amt, price_amt,
@@ -1254,7 +1252,7 @@ language plpgsql;
grant execute on function public.upsert_subscription(uuid, varchar, grant execute on function public.upsert_subscription(uuid, varchar,
text, bool, public.subscription_status, public.billing_provider, text, bool, public.subscription_status, public.billing_provider,
bool, varchar, timestamptz, timestamptz, jsonb, timestamptz, 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 -- account is their own
create policy orders_read_self on public.orders create policy orders_read_self on public.orders
for select to authenticated for select to authenticated
using ((account_id = auth.uid() and config.is_set('enable_account_billing')) using ((account_id = auth.uid() and public.is_set('enable_account_billing'))
or (has_role_on_account(account_id) and config.is_set('enable_team_account_billing'))); or (has_role_on_account(account_id) and public.is_set('enable_team_account_billing')));
/** /**