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'))); /**