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,