From 84a4b45bcdb6677fbc3b73da41a28acd61dc2595 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Mon, 1 Apr 2024 20:58:26 +0800 Subject: [PATCH] Update billing system to support single and recurring payments This update modifies the billing system to properly handle both single and recurring payment plans. Logic is introduced to determine whether the selected plan is recurring or a one-time payment and adjust the interface accordingly. The naming of some components and variables has been changed to more accurately reflect their purpose. Additionally, a --- .../personal-account-checkout-form.tsx | 6 +- .../(dashboard)/home/(user)/billing/page.tsx | 6 +- .../team-account-checkout-form.tsx | 6 +- .../home/[account]/billing/page.tsx | 9 +- .../home/[account]/members/page.tsx | 2 +- apps/web/config/billing.config.ts | 23 +++ apps/web/public/locales/en/billing.json | 18 +- .../current-lifetime-order-card.tsx | 94 ++++++++++ .../src/components/current-plan-badge.tsx | 9 +- ...card.tsx => current-subscription-card.tsx} | 12 +- .../billing-gateway/src/components/index.ts | 3 +- .../src/components/line-item-details.tsx | 16 +- .../src/components/plan-picker.tsx | 163 +++++++++++------- .../billing-event-handler.service.ts | 10 +- packages/billing/src/create-billing-schema.ts | 6 +- .../account-settings-container.tsx | 2 +- .../team-account-settings-container.tsx | 2 +- .../components/stripe-embedded-checkout.tsx | 4 - .../src/services/create-stripe-checkout.ts | 1 + .../stripe-webhook-handler.service.ts | 2 +- packages/supabase/src/database.types.ts | 10 +- supabase/migrations/20221215192558_schema.sql | 6 +- 22 files changed, 291 insertions(+), 119 deletions(-) create mode 100644 packages/billing-gateway/src/components/current-lifetime-order-card.tsx rename packages/billing-gateway/src/components/{current-plan-card.tsx => current-subscription-card.tsx} (95%) diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx index 243b40340..7d69a3f36 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -25,7 +25,10 @@ export function PersonalAccountCheckoutForm(props: { }) { const [pending, startTransition] = useTransition(); const [error, setError] = useState(false); - const [checkoutToken, setCheckoutToken] = useState(); + + const [checkoutToken, setCheckoutToken] = useState( + undefined, + ); // only allow trial if the user is not already a customer const canStartTrial = !props.customerId; @@ -36,6 +39,7 @@ export function PersonalAccountCheckoutForm(props: { setCheckoutToken(undefined)} /> ); } diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx index a369d5a2c..d7770f364 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx @@ -2,7 +2,7 @@ import { SupabaseClient } from '@supabase/supabase-js'; import { BillingPortalCard, - CurrentPlanCard, + CurrentSubscriptionCard, } from '@kit/billing-gateway/components'; import { Database } from '@kit/supabase/database'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; @@ -51,9 +51,9 @@ async function PersonalAccountBillingPage() { {(subscription) => (
- diff --git a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx index 0e847db07..cb2d9b21a 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx @@ -24,7 +24,10 @@ export function TeamAccountCheckoutForm(params: { }) { const routeParams = useParams(); const [pending, startTransition] = useTransition(); - const [checkoutToken, setCheckoutToken] = useState(null); + + const [checkoutToken, setCheckoutToken] = useState( + undefined, + ); // If the checkout token is set, render the embedded checkout component if (checkoutToken) { @@ -32,6 +35,7 @@ export function TeamAccountCheckoutForm(params: { setCheckoutToken(undefined)} /> ); } diff --git a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx index 9bb81dff0..396f28fd6 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx @@ -1,6 +1,6 @@ import { BillingPortalCard, - CurrentPlanCard, + CurrentSubscriptionCard, } from '@kit/billing-gateway/components'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -53,7 +53,7 @@ async function TeamAccountBillingPage({ params }: Params) {
-
+
{(data) => ( - + )} diff --git a/apps/web/app/(dashboard)/home/[account]/members/page.tsx b/apps/web/app/(dashboard)/home/[account]/members/page.tsx index 1c0c06383..4a492b51c 100644 --- a/apps/web/app/(dashboard)/home/[account]/members/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/members/page.tsx @@ -114,7 +114,7 @@ async function TeamAccountMembersPage({ params }: Params) {
diff --git a/apps/web/config/billing.config.ts b/apps/web/config/billing.config.ts index a43e45f91..e75a8e85b 100644 --- a/apps/web/config/billing.config.ts +++ b/apps/web/config/billing.config.ts @@ -7,6 +7,29 @@ const provider = BillingProviderSchema.parse( export default createBillingSchema({ provider, products: [ + { + id: 'lifetime', + name: 'Lifetime', + description: 'The perfect plan for a lifetime', + currency: 'USD', + features: ['Feature 1', 'Feature 2', 'Feature 3'], + plans: [ + { + name: 'Lifetime', + id: 'lifetime', + paymentType: 'one-time', + lineItems: [ + { + id: 'price_1P0jgcI1i3VnbZTqXVXaZkMP', + name: 'Base', + description: 'Base plan', + cost: 999.99, + type: 'base', + }, + ], + }, + ], + }, { id: 'starter', name: 'Starter', diff --git a/apps/web/public/locales/en/billing.json b/apps/web/public/locales/en/billing.json index fc168a2c2..69b53d81d 100644 --- a/apps/web/public/locales/en/billing.json +++ b/apps/web/public/locales/en/billing.json @@ -20,12 +20,13 @@ "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your organization owner.", "manageTeamPlan": "Manage your Team Plan", "manageTeamPlanDescription": "Choose a plan that fits your team's needs. You can upgrade or downgrade your plan at any time.", - "flatSubscription": "Flat Subscription", + "basePlan": "Base Plan", "billingInterval": { "label": "Choose your billing interval", "month": "Billed monthly", "year": "Billed yearly" }, + "lifetime": "Lifetime", "trialPeriod": "{{period}} day trial", "perPeriod": "per {{period}}", "processing": "Processing...", @@ -85,6 +86,21 @@ "badge": "Paused", "heading": "Your subscription is paused", "description": "Your subscription is paused. You can resume it at any time." + }, + "succeeded": { + "badge": "Succeeded", + "heading": "Your payment was successful", + "description": "Your payment was successful. Thank you for subscribing!" + }, + "pending": { + "badge": "Pending", + "heading": "Your payment is pending", + "description": "Your payment is pending. Please bear with us." + }, + "failed": { + "badge": "Failed", + "heading": "Your payment failed", + "description": "Your payment failed. Please update your payment method." } } } diff --git a/packages/billing-gateway/src/components/current-lifetime-order-card.tsx b/packages/billing-gateway/src/components/current-lifetime-order-card.tsx new file mode 100644 index 000000000..b322c0b24 --- /dev/null +++ b/packages/billing-gateway/src/components/current-lifetime-order-card.tsx @@ -0,0 +1,94 @@ +import { BadgeCheck } from 'lucide-react'; + +import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing'; +import { Database } from '@kit/supabase/database'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { Trans } from '@kit/ui/trans'; + +import { CurrentPlanBadge } from './current-plan-badge'; +import { LineItemDetails } from './line-item-details'; + +type Order = Database['public']['Tables']['orders']['Row']; +type LineItem = Database['public']['Tables']['order_items']['Row']; + +interface Props { + order: Order & { + items: LineItem[]; + }; + + config: BillingConfig; +} + +export function CurrentLifetimeOrderCard({ + order, + config, +}: React.PropsWithChildren) { + const lineItems = order.items; + const firstLineItem = lineItems[0]; + + if (!firstLineItem) { + throw new Error('No line items found in subscription'); + } + + const { product, plan } = getProductPlanPairByVariantId( + config, + firstLineItem.variant_id, + ); + + if (!product || !plan) { + throw new Error( + 'Product or plan not found. Did you forget to add it to the billing config?', + ); + } + + const productLineItems = plan.lineItems; + + return ( + + + + + + + + + + + + +
+
+ + + {product.name} + + +
+
+ +
+
+ + + + + +
+
+
+
+ ); +} diff --git a/packages/billing-gateway/src/components/current-plan-badge.tsx b/packages/billing-gateway/src/components/current-plan-badge.tsx index 5844b4b32..d8de500f8 100644 --- a/packages/billing-gateway/src/components/current-plan-badge.tsx +++ b/packages/billing-gateway/src/components/current-plan-badge.tsx @@ -2,9 +2,13 @@ import { Database } from '@kit/supabase/database'; import { Badge } from '@kit/ui/badge'; import { Trans } from '@kit/ui/trans'; +type Status = + | Database['public']['Enums']['subscription_status'] + | Database['public']['Enums']['payment_status']; + export function CurrentPlanBadge( props: React.PropsWithoutRef<{ - status: Database['public']['Enums']['subscription_status']; + status: Status; }>, ) { let variant: 'success' | 'warning' | 'destructive'; @@ -12,12 +16,14 @@ export function CurrentPlanBadge( switch (props.status) { case 'active': + case 'succeeded': variant = 'success'; break; case 'trialing': variant = 'success'; break; case 'past_due': + case 'failed': variant = 'destructive'; break; case 'canceled': @@ -27,6 +33,7 @@ export function CurrentPlanBadge( variant = 'destructive'; break; case 'incomplete': + case 'pending': variant = 'warning'; break; case 'incomplete_expired': diff --git a/packages/billing-gateway/src/components/current-plan-card.tsx b/packages/billing-gateway/src/components/current-subscription-card.tsx similarity index 95% rename from packages/billing-gateway/src/components/current-plan-card.tsx rename to packages/billing-gateway/src/components/current-subscription-card.tsx index 7d8663be1..d6fc0aefd 100644 --- a/packages/billing-gateway/src/components/current-plan-card.tsx +++ b/packages/billing-gateway/src/components/current-subscription-card.tsx @@ -1,14 +1,10 @@ import { formatDate } from 'date-fns'; -import { BadgeCheck, CheckCircle2 } from 'lucide-react'; +import { BadgeCheck } from 'lucide-react'; import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing'; import { Database } from '@kit/supabase/database'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@kit/ui/accordion'; + + import { Card, CardContent, @@ -34,7 +30,7 @@ interface Props { config: BillingConfig; } -export function CurrentPlanCard({ +export function CurrentSubscriptionCard({ subscription, config, }: React.PropsWithChildren) { diff --git a/packages/billing-gateway/src/components/index.ts b/packages/billing-gateway/src/components/index.ts index 0ba655853..c673574f0 100644 --- a/packages/billing-gateway/src/components/index.ts +++ b/packages/billing-gateway/src/components/index.ts @@ -1,5 +1,6 @@ export * from './plan-picker'; -export * from './current-plan-card'; +export * from './current-subscription-card'; +export * from './current-lifetime-order-card'; export * from './embedded-checkout'; export * from './billing-session-status'; export * from './billing-portal-card'; diff --git a/packages/billing-gateway/src/components/line-item-details.tsx b/packages/billing-gateway/src/components/line-item-details.tsx index ea5238d53..34448b4b3 100644 --- a/packages/billing-gateway/src/components/line-item-details.tsx +++ b/packages/billing-gateway/src/components/line-item-details.tsx @@ -2,13 +2,14 @@ import { z } from 'zod'; import { LineItemSchema } from '@kit/billing'; import { formatCurrency } from '@kit/shared/utils'; +import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; export function LineItemDetails( props: React.PropsWithChildren<{ lineItems: z.infer[]; currency: string; - selectedInterval: string; + selectedInterval?: string | undefined; }>, ) { return ( @@ -23,15 +24,20 @@ export function LineItemDetails( > - + / - + } + > + + diff --git a/packages/billing-gateway/src/components/plan-picker.tsx b/packages/billing-gateway/src/components/plan-picker.tsx index f7981b69c..e6dfd3d60 100644 --- a/packages/billing-gateway/src/components/plan-picker.tsx +++ b/packages/billing-gateway/src/components/plan-picker.tsx @@ -101,6 +101,10 @@ export function PlanPicker( const { t } = useTranslation(`billing`); + // display the period picker if the selected plan is recurring or if no plan is selected + const isRecurringPlan = + selectedPlan?.paymentType === 'recurring' || !selectedPlan; + return (
- { - return ( - - - - +
+ { + return ( + + + + - - -
- {intervals.map((interval) => { - const selected = field.value === interval; + + +
+ {intervals.map((interval) => { + const selected = field.value === interval; - return ( -
-
-
- - - ); - }} - /> + + + + + + ); + })} +
+
+
+ + +
+ ); + }} + /> +
{props.config.products.map((product) => { - const plan = product.plans.find( - (item) => item.interval === selectedInterval, - ); + const plan = product.plans.find((item) => { + if (item.paymentType === 'one-time') { + return true; + } + + return item.interval === selectedInterval; + }); if (!plan) { return null; @@ -277,12 +292,21 @@ export function PlanPicker(
- + + } + > + +
@@ -348,8 +372,11 @@ function PlanDetails({ selectedPlan: { lineItems: z.infer[]; + paymentType: string; }; }) { + const isRecurring = selectedPlan.paymentType === 'recurring'; + return (
{' '} - / + + / +

@@ -384,7 +413,7 @@ function PlanDetails({

diff --git a/packages/billing-gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts b/packages/billing-gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts index d1ed4482c..8a9ca667b 100644 --- a/packages/billing-gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts +++ b/packages/billing-gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts @@ -81,20 +81,20 @@ export class BillingEventHandlerService { Logger.info(ctx, 'Successfully updated subscription'); }, - onCheckoutSessionCompleted: async (payload, customerId) => { + onCheckoutSessionCompleted: async (payload) => { // Handle the checkout session completed event // here we add the subscription to the database const client = this.clientProvider(); // Check if the payload contains an order_id // if it does, we add an order, otherwise we add a subscription - if ('order_id' in payload) { + if ('target_order_id' in payload) { const ctx = { namespace: this.namespace, - orderId: payload.order_id, + orderId: payload.target_order_id, provider: payload.billing_provider, accountId: payload.target_account_id, - customerId, + customerId: payload.target_customer_id, }; Logger.info(ctx, 'Processing order completed event...'); @@ -114,7 +114,7 @@ export class BillingEventHandlerService { subscriptionId: payload.target_subscription_id, provider: payload.billing_provider, accountId: payload.target_account_id, - customerId, + customerId: payload.target_customer_id, }; Logger.info(ctx, 'Processing checkout session completed event...'); diff --git a/packages/billing/src/create-billing-schema.ts b/packages/billing/src/create-billing-schema.ts index 1d9a73282..01ffe7a54 100644 --- a/packages/billing/src/create-billing-schema.ts +++ b/packages/billing/src/create-billing-schema.ts @@ -186,9 +186,9 @@ export type BillingConfig = z.infer; export type ProductSchema = z.infer; export function getPlanIntervals(config: z.infer) { - const intervals = config.products.flatMap((product) => - product.plans.map((plan) => plan.interval), - ); + const intervals = config.products + .flatMap((product) => product.plans.map((plan) => plan.interval)) + .filter(Boolean); return Array.from(new Set(intervals)); } diff --git a/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx b/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx index 38be59754..c1d3f8c92 100644 --- a/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx @@ -29,7 +29,7 @@ export function PersonalAccountSettingsContainer( }>, ) { return ( -
+
diff --git a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx index ae095c937..819be37ad 100644 --- a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx +++ b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx @@ -27,7 +27,7 @@ export function TeamAccountSettingsContainer(props: { }; }) { return ( -
+
diff --git a/packages/stripe/src/components/stripe-embedded-checkout.tsx b/packages/stripe/src/components/stripe-embedded-checkout.tsx index 722d256c7..7111c4284 100644 --- a/packages/stripe/src/components/stripe-embedded-checkout.tsx +++ b/packages/stripe/src/components/stripe-embedded-checkout.tsx @@ -63,10 +63,6 @@ function EmbeddedCheckoutPopup({ setOpen(open); }} > - - Complete your purchase - - e.preventDefault()} diff --git a/packages/stripe/src/services/create-stripe-checkout.ts b/packages/stripe/src/services/create-stripe-checkout.ts index a25ec2b9b..25a51bfa7 100644 --- a/packages/stripe/src/services/create-stripe-checkout.ts +++ b/packages/stripe/src/services/create-stripe-checkout.ts @@ -73,6 +73,7 @@ export async function createStripeCheckout( line_items: lineItems, client_reference_id: clientReferenceId, subscription_data: subscriptionData, + customer_creation: 'always', ...customerData, ...urls, }); diff --git a/packages/stripe/src/services/stripe-webhook-handler.service.ts b/packages/stripe/src/services/stripe-webhook-handler.service.ts index 3c2efc025..dd666d645 100644 --- a/packages/stripe/src/services/stripe-webhook-handler.service.ts +++ b/packages/stripe/src/services/stripe-webhook-handler.service.ts @@ -171,7 +171,7 @@ export class StripeWebhookHandlerService const payload: UpsertOrderParams = { target_account_id: accountId, target_customer_id: customerId, - order_id: sessionId, + target_order_id: sessionId, billing_provider: this.provider, status: status, currency: currency, diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index dcd647ee5..bd6e14fc7 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -410,11 +410,9 @@ export type Database = { created_at: string currency: string id: string - product_id: string status: Database["public"]["Enums"]["payment_status"] total_amount: number updated_at: string - variant_id: string } Insert: { account_id: string @@ -423,11 +421,9 @@ export type Database = { created_at?: string currency: string id: string - product_id: string status: Database["public"]["Enums"]["payment_status"] total_amount: number updated_at?: string - variant_id: string } Update: { account_id?: string @@ -436,11 +432,9 @@ export type Database = { created_at?: string currency?: string id?: string - product_id?: string status?: Database["public"]["Enums"]["payment_status"] total_amount?: number updated_at?: string - variant_id?: string } Relationships: [ { @@ -891,7 +885,7 @@ export type Database = { Args: { target_account_id: string target_customer_id: string - order_id: string + target_order_id: string status: Database["public"]["Enums"]["payment_status"] billing_provider: Database["public"]["Enums"]["billing_provider"] total_amount: number @@ -905,11 +899,9 @@ export type Database = { created_at: string currency: string id: string - product_id: string status: Database["public"]["Enums"]["payment_status"] total_amount: number updated_at: string - variant_id: string } } upsert_subscription: { diff --git a/supabase/migrations/20221215192558_schema.sql b/supabase/migrations/20221215192558_schema.sql index 75d8131bc..8ab72fbfb 100644 --- a/supabase/migrations/20221215192558_schema.sql +++ b/supabase/migrations/20221215192558_schema.sql @@ -1249,7 +1249,7 @@ select create or replace function public.upsert_order( target_account_id uuid, target_customer_id varchar(255), - order_id text, + target_order_id text, status public.payment_status, billing_provider public.billing_provider, total_amount numeric, @@ -1261,7 +1261,7 @@ declare new_billing_customer_id int; begin insert into public.billing_customers(account_id, provider, customer_id) - values (target_account_id, target_billing_provider, target_customer_id) + values (target_account_id, billing_provider, target_customer_id) on conflict (account_id, provider, customer_id) do update set provider = excluded.provider returning id into new_billing_customer_id; @@ -1277,7 +1277,7 @@ begin values ( target_account_id, new_billing_customer_id, - order_id, + target_order_id, status, billing_provider, total_amount,