diff --git a/.changeset/shy-loops-type.md b/.changeset/shy-loops-type.md new file mode 100644 index 00000000000..60027067651 --- /dev/null +++ b/.changeset/shy-loops-type.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add support for account credits in checkout. diff --git a/packages/clerk-js/sandbox/scenarios/checkout-account-credit.ts b/packages/clerk-js/sandbox/scenarios/checkout-account-credit.ts new file mode 100644 index 00000000000..bd2c70fa797 --- /dev/null +++ b/packages/clerk-js/sandbox/scenarios/checkout-account-credit.ts @@ -0,0 +1,336 @@ +import { + clerkHandlers, + http, + HttpResponse, + EnvironmentService, + SessionService, + setClerkState, + type MockScenario, + UserService, +} from '@clerk/msw'; + +export function CheckoutAccountCredit(): MockScenario { + const user = UserService.create(); + const session = SessionService.create(user); + + setClerkState({ + environment: EnvironmentService.MULTI_SESSION, + session, + user, + }); + + const subscriptionHandler = http.get('https://*.clerk.accounts.dev/v1/me/billing/subscription', () => { + return HttpResponse.json({ + response: { + data: {}, + }, + }); + }); + + const paymentMethodsHandler = http.get('https://*.clerk.accounts.dev/v1/me/billing/payment_methods', () => { + return HttpResponse.json({ + response: { + data: {}, + }, + }); + }); + + const checkoutAccountCreditHandler = http.post('https://*.clerk.accounts.dev/v1/me/billing/checkouts', () => { + return HttpResponse.json({ + response: { + object: 'commerce_checkout', + id: 'string', + plan: { + object: 'commerce_plan', + id: 'string', + name: 'Pro', + fee: { + amount: 0, + amount_formatted: '25.00', + currency: 'string', + currency_symbol: '$', + }, + annual_monthly_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + annual_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + description: null, + is_default: true, + is_recurring: true, + publicly_visible: true, + has_base_fee: true, + for_payer_type: 'string', + slug: 'string', + avatar_url: null, + free_trial_enabled: true, + free_trial_days: null, + features: [ + { + object: 'feature', + id: 'string', + name: 'string', + description: null, + slug: 'string', + avatar_url: null, + }, + ], + }, + plan_period: 'month', + payer: { + object: 'commerce_payer', + id: 'string', + instance_id: 'string', + user_id: null, + first_name: null, + last_name: null, + email: null, + organization_id: null, + organization_name: null, + image_url: 'https://example.com', + created_at: 1, + updated_at: 1, + }, + payment_method: { + object: 'commerce_payment_method', + id: 'string', + payer_id: 'string', + payment_type: 'card', + is_default: true, + gateway: 'string', + gateway_external_id: 'string', + gateway_external_account_id: null, + last4: null, + status: 'active', + wallet_type: null, + card_type: null, + expiry_year: null, + expiry_month: null, + created_at: 1, + updated_at: 1, + is_removable: true, + }, + external_gateway_id: 'string', + status: 'needs_confirmation', + totals: { + subtotal: { + amount: 1, + amount_formatted: '25.00', + currency: 'string', + currency_symbol: '$', + }, + tax_total: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + grand_total: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + total_due_after_free_trial: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + total_due_now: { + amount: 1, + amount_formatted: '10.00', + currency: 'string', + currency_symbol: '$', + }, + past_due: null, + credit: { + amount: 1, + amount_formatted: '5.00', + currency: 'string', + currency_symbol: '$', + }, + credits: { + proration: { + amount: { + amount: 1, + amount_formatted: '5.00', + currency: 'string', + currency_symbol: '$', + }, + cycle_days_remaining: 1, + cycle_days_total: 1, + cycle_remaining_percent: 1, + }, + payer: { + remaining_balance: { + amount: 1, + amount_formatted: '100.00', + currency: 'string', + currency_symbol: '$', + }, + applied_amount: { + amount: 1, + amount_formatted: '10.00', + currency: 'string', + currency_symbol: '$', + }, + }, + total: { + amount: 1, + amount_formatted: '15.00', + currency: 'string', + currency_symbol: '$', + }, + }, + }, + subscription_item: { + object: 'commerce_subscription_item', + id: 'string', + instance_id: 'string', + status: 'active', + credit: { + amount: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + cycle_days_remaining: 1, + cycle_days_total: 1, + cycle_remaining_percent: 1, + }, + plan_id: 'string', + price_id: 'string', + plan: { + object: 'commerce_plan', + id: 'string', + name: 'string', + fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + annual_monthly_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + annual_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + description: null, + is_default: true, + is_recurring: true, + publicly_visible: true, + has_base_fee: true, + for_payer_type: 'string', + slug: 'string', + avatar_url: null, + free_trial_enabled: true, + free_trial_days: null, + features: [ + { + object: 'feature', + id: 'string', + name: 'string', + description: null, + slug: 'string', + avatar_url: null, + }, + ], + }, + plan_period: 'month', + payment_method_id: 'string', + payment_method: { + object: 'commerce_payment_method', + id: 'string', + payer_id: 'string', + payment_type: 'card', + is_default: true, + gateway: 'string', + gateway_external_id: 'string', + gateway_external_account_id: null, + last4: null, + status: 'active', + wallet_type: null, + card_type: null, + expiry_year: null, + expiry_month: null, + created_at: 1, + updated_at: 1, + is_removable: true, + }, + lifetime_paid: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + amount: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + next_payment: { + amount: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + date: 1, + }, + payer_id: 'string', + payer: { + object: 'commerce_payer', + id: 'string', + instance_id: 'string', + user_id: null, + first_name: null, + last_name: null, + email: null, + organization_id: null, + organization_name: null, + image_url: 'https://example.com', + created_at: 1, + updated_at: 1, + }, + is_free_trial: true, + period_start: 1, + period_end: null, + proration_date: 'string', + canceled_at: null, + past_due_at: null, + ended_at: null, + created_at: 1, + updated_at: 1, + }, + plan_period_start: 1, + is_immediate_plan_change: true, + free_trial_ends_at: 1, + needs_payment_method: true, + }, + }); + }); + + return { + description: 'Checkout with account credit', + handlers: [checkoutAccountCreditHandler, subscriptionHandler, paymentMethodsHandler, ...clerkHandlers], + initialState: { session, user }, + name: 'checkout-account-credit', + }; +} diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts index 988c7ecf0f9..73ddfca0ce6 100644 --- a/packages/clerk-js/sandbox/scenarios/index.ts +++ b/packages/clerk-js/sandbox/scenarios/index.ts @@ -1 +1,2 @@ export { UserButtonSignedIn } from './user-button-signed-in'; +export { CheckoutAccountCredit } from './checkout-account-credit'; 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 fd6124f1337..05e5eea58dd 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -690,6 +690,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. * @@ -716,6 +734,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 b16cc368acc..9b3ca7d6ee3 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -696,6 +696,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { credit?: { amount: BillingMoneyAmountJSON; }; + credits?: BillingCreditsJSON; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; status: BillingSubscriptionStatus; @@ -745,6 +746,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. */ @@ -754,6 +782,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 d97301630d0..d81be772c21 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; diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 187290becae..7fc80d0979f 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/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 && (