diff --git a/.changeset/shy-loops-type.md b/.changeset/shy-loops-type.md new file mode 100644 index 00000000000..b55584f9cd2 --- /dev/null +++ b/.changeset/shy-loops-type.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add support for account credits in checkout. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c96f7bd1fb8..9ed455d99ca 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -24,7 +24,7 @@ { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/enableOrganizationsPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" }, - { "path": "./dist/checkout*.js", "maxSize": "8.82KB" }, + { "path": "./dist/checkout*.js", "maxSize": "10KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" }, diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 7d7c7a4f2b1..cad7532cc74 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -35,7 +35,8 @@ export const CheckoutForm = withCardStateProvider(() => { return null; } - const showCredits = !!totals.credit?.amount && totals.credit.amount > 0; + const showProratedCredit = !!totals.credits?.proration?.amount && totals.credits.proration.amount.amount > 0; + const showAccountCredits = !!totals.credits?.payer?.appliedAmount && totals.credits.payer.appliedAmount.amount > 0; const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; const showDowngradeInfo = !isImmediatePlanChange; @@ -80,10 +81,20 @@ export const CheckoutForm = withCardStateProvider(() => { - {showCredits && ( + {showProratedCredit && ( - + + + )} + {showAccountCredits && ( + + + )} {showPastDue && ( diff --git a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx index 74402763745..8414f0c9ea8 100644 --- a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx @@ -309,6 +309,113 @@ describe('Checkout', () => { }); }); + it('renders credit details', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); + }); + + fixtures.clerk.user?.getPaymentMethods.mockResolvedValue({ + data: [], + total_count: 0, + }); + + fixtures.clerk.billing.startCheckout.mockResolvedValue({ + id: 'chk_credits_1', + status: 'needs_confirmation', + externalClientSecret: 'cs_test_credits_1', + externalGatewayId: 'gw_test', + totals: { + subtotal: { amount: 2500, amountFormatted: '25.00', currency: 'USD', currencySymbol: '$' }, + grandTotal: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + taxTotal: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + credits: { + proration: { + amount: { amount: 500, amountFormatted: '5.00', currency: 'USD', currencySymbol: '$' }, + cycleDaysRemaining: 15, + cycleDaysTotal: 30, + cycleRemainingPercent: 50, + }, + payer: { + remainingBalance: { amount: 2000, amountFormatted: '20.00', currency: 'USD', currencySymbol: '$' }, + appliedAmount: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + }, + total: { amount: 1500, amountFormatted: '15.00', currency: 'USD', currencySymbol: '$' }, + }, + pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDueNow: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + }, + isImmediatePlanChange: true, + planPeriod: 'month', + plan: { + id: 'plan_credits', + name: 'Pro', + description: 'Pro plan', + features: [], + fee: { + amount: 2500, + amountFormatted: '25.00', + currency: 'USD', + currencySymbol: '$', + }, + annualFee: { + amount: 30000, + amountFormatted: '300.00', + currency: 'USD', + currencySymbol: '$', + }, + annualMonthlyFee: { + amount: 2500, + amountFormatted: '25.00', + currency: 'USD', + currencySymbol: '$', + }, + slug: 'pro', + avatarUrl: '', + publiclyVisible: true, + isDefault: true, + isRecurring: true, + hasBaseFee: false, + forPayerType: 'user', + freeTrialDays: 7, + freeTrialEnabled: true, + }, + paymentMethod: undefined, + confirm: vi.fn(), + freeTrialEndsAt: null, + needsPaymentMethod: false, + } as any); + + const { getByRole, getByText } = render( + {}} + > + + , + { wrapper }, + ); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Checkout' })).toBeVisible(); + }); + + const prorationCreditRow = getByText('Credit for the remainder of your current subscription.').closest( + '.cl-lineItemsGroup', + ); + const accountCreditRow = getByText('Credit from account balance.').closest('.cl-lineItemsGroup'); + + expect(prorationCreditRow).toBeInTheDocument(); + expect(accountCreditRow).toBeInTheDocument(); + + expect(prorationCreditRow).toHaveTextContent('- $5.00'); + expect(accountCreditRow).toHaveTextContent('- $10.00'); + }); + it('renders free trial details during confirmation stage', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index a72868a859d..8c42f1a86fa 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -1,6 +1,8 @@ import type { BillingCheckoutTotals, BillingCheckoutTotalsJSON, + BillingCredits, + BillingCreditsJSON, BillingMoneyAmount, BillingMoneyAmountJSON, BillingStatementTotals, @@ -16,6 +18,26 @@ export const billingMoneyAmountFromJSON = (data: BillingMoneyAmountJSON): Billin }; }; +const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits => { + return { + proration: data.proration + ? { + amount: billingMoneyAmountFromJSON(data.proration.amount), + cycleDaysRemaining: data.proration.cycle_days_remaining, + cycleDaysTotal: data.proration.cycle_days_total, + cycleRemainingPercent: data.proration.cycle_remaining_percent, + } + : null, + payer: data.payer + ? { + remainingBalance: billingMoneyAmountFromJSON(data.payer.remaining_balance), + appliedAmount: billingMoneyAmountFromJSON(data.payer.applied_amount), + } + : null, + total: billingMoneyAmountFromJSON(data.total), + }; +}; + export const billingTotalsFromJSON = ( data: T, ): T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals => { @@ -31,7 +53,9 @@ export const billingTotalsFromJSON = { totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, totalDueAfterFreeTrial: null, credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, + credits: { + proration: null, + payer: null, + total: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, + }, pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, }, status: 'needs_confirmation' as const, diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 26f11f7e4ea..a7ba45ae532 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -645,6 +645,24 @@ export interface BillingMoneyAmount { currencySymbol: string; } +export interface BillingProrationCreditDetail { + amount: BillingMoneyAmount; + cycleDaysRemaining: number; + cycleDaysTotal: number; + cycleRemainingPercent: number; +} + +export interface BillingPayerCredit { + remainingBalance: BillingMoneyAmount; + appliedAmount: BillingMoneyAmount; +} + +export interface BillingCredits { + proration: BillingProrationCreditDetail | null; + payer: BillingPayerCredit | null; + total: BillingMoneyAmount; +} + /** * The `BillingCheckoutTotals` type represents the total costs, taxes, and other pricing details for a checkout session. * @@ -671,6 +689,7 @@ export interface BillingCheckoutTotals { * Any credits (like account balance or promo credits) that are being applied to the checkout. */ credit: BillingMoneyAmount | null; + credits: BillingCredits | null; /** * Any outstanding amount from previous unpaid invoices that is being collected as part of the checkout. */ diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index e24e426cca2..783395c5fa9 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -730,6 +730,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { credit?: { amount: BillingMoneyAmountJSON; }; + credits?: BillingCreditsJSON; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; status: BillingSubscriptionStatus; @@ -779,6 +780,33 @@ export interface BillingMoneyAmountJSON { currency_symbol: string; } +/** + * Contains proration credit details including billing cycle information. + */ +export interface BillingProrationCreditDetailJSON { + amount: BillingMoneyAmountJSON; + cycle_days_remaining: number; + cycle_days_total: number; + cycle_remaining_percent: number; +} + +/** + * Contains payer credit details including the available balance and the amount applied to this checkout. + */ +export interface BillingPayerCreditJSON { + remaining_balance: BillingMoneyAmountJSON; + applied_amount: BillingMoneyAmountJSON; +} + +/** + * Unified credits breakdown for checkout totals. Can be used instead of `credit` field. + */ +export interface BillingCreditsJSON { + proration: BillingProrationCreditDetailJSON | null; + payer: BillingPayerCreditJSON | null; + total: BillingMoneyAmountJSON; +} + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ @@ -788,6 +816,8 @@ export interface BillingCheckoutTotalsJSON { tax_total: BillingMoneyAmountJSON; total_due_now: BillingMoneyAmountJSON; credit: BillingMoneyAmountJSON | null; + credits: BillingCreditsJSON | null; + account_credit: BillingMoneyAmountJSON | null; past_due: BillingMoneyAmountJSON | null; total_due_after_free_trial: BillingMoneyAmountJSON | null; } diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7c3b5ae0fc0..b7eb936734a 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -207,6 +207,7 @@ export type __internal_LocalizationResource = { subtotal: LocalizationValue; credit: LocalizationValue; creditRemainder: LocalizationValue; + payerCreditRemainder: LocalizationValue; totalDue: LocalizationValue; totalDueToday: LocalizationValue; pastDue: LocalizationValue;