Skip to content

Commit 5f6e4a4

Browse files
committed
feat(clerk-js,localizations,shared,ui): Add support for credits (#7776)
1 parent 9cdc7f0 commit 5f6e4a4

9 files changed

Lines changed: 210 additions & 4 deletions

File tree

.changeset/shy-loops-type.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/shared': minor
5+
'@clerk/ui': minor
6+
---
7+
8+
Add support for account credits in checkout.

packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export const CheckoutForm = withCardStateProvider(() => {
3535
return null;
3636
}
3737

38-
const showCredits = !!totals.credit?.amount && totals.credit.amount > 0;
38+
const showProratedCredit = !!totals.credits?.proration?.amount && totals.credits.proration.amount.amount > 0;
39+
const showAccountCredits = !!totals.credits?.payer?.appliedAmount && totals.credits.payer.appliedAmount.amount > 0;
3940
const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0;
4041
const showDowngradeInfo = !isImmediatePlanChange;
4142

@@ -80,10 +81,20 @@ export const CheckoutForm = withCardStateProvider(() => {
8081
<LineItems.Title title={localizationKeys('billing.subtotal')} />
8182
<LineItems.Description text={`${totals.subtotal.currencySymbol}${totals.subtotal.amountFormatted}`} />
8283
</LineItems.Group>
83-
{showCredits && (
84+
{showProratedCredit && (
8485
<LineItems.Group variant='tertiary'>
8586
<LineItems.Title title={localizationKeys('billing.creditRemainder')} />
86-
<LineItems.Description text={`- ${totals.credit?.currencySymbol}${totals.credit?.amountFormatted}`} />
87+
<LineItems.Description
88+
text={`- ${totals.credits?.proration?.amount.currencySymbol}${totals.credits?.proration?.amount.amountFormatted}`}
89+
/>
90+
</LineItems.Group>
91+
)}
92+
{showAccountCredits && (
93+
<LineItems.Group variant='tertiary'>
94+
<LineItems.Title title={localizationKeys('billing.payerCreditRemainder')} />
95+
<LineItems.Description
96+
text={`- ${totals.credits?.payer?.appliedAmount?.currencySymbol}${totals.credits?.payer?.appliedAmount?.amountFormatted}`}
97+
/>
8798
</LineItems.Group>
8899
)}
89100
{showPastDue && (

packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,113 @@ describe('Checkout', () => {
309309
});
310310
});
311311

312+
it('renders credit details', async () => {
313+
const { wrapper, fixtures } = await createFixtures(f => {
314+
f.withUser({ email_addresses: ['test@clerk.com'] });
315+
f.withBilling();
316+
});
317+
318+
fixtures.clerk.user?.getPaymentMethods.mockResolvedValue({
319+
data: [],
320+
total_count: 0,
321+
});
322+
323+
fixtures.clerk.billing.startCheckout.mockResolvedValue({
324+
id: 'chk_credits_1',
325+
status: 'needs_confirmation',
326+
externalClientSecret: 'cs_test_credits_1',
327+
externalGatewayId: 'gw_test',
328+
totals: {
329+
subtotal: { amount: 2500, amountFormatted: '25.00', currency: 'USD', currencySymbol: '$' },
330+
grandTotal: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
331+
taxTotal: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
332+
credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
333+
credits: {
334+
proration: {
335+
amount: { amount: 500, amountFormatted: '5.00', currency: 'USD', currencySymbol: '$' },
336+
cycleDaysRemaining: 15,
337+
cycleDaysTotal: 30,
338+
cycleRemainingPercent: 50,
339+
},
340+
payer: {
341+
remainingBalance: { amount: 2000, amountFormatted: '20.00', currency: 'USD', currencySymbol: '$' },
342+
appliedAmount: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
343+
},
344+
total: { amount: 1500, amountFormatted: '15.00', currency: 'USD', currencySymbol: '$' },
345+
},
346+
pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
347+
totalDueNow: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
348+
},
349+
isImmediatePlanChange: true,
350+
planPeriod: 'month',
351+
plan: {
352+
id: 'plan_credits',
353+
name: 'Pro',
354+
description: 'Pro plan',
355+
features: [],
356+
fee: {
357+
amount: 2500,
358+
amountFormatted: '25.00',
359+
currency: 'USD',
360+
currencySymbol: '$',
361+
},
362+
annualFee: {
363+
amount: 30000,
364+
amountFormatted: '300.00',
365+
currency: 'USD',
366+
currencySymbol: '$',
367+
},
368+
annualMonthlyFee: {
369+
amount: 2500,
370+
amountFormatted: '25.00',
371+
currency: 'USD',
372+
currencySymbol: '$',
373+
},
374+
slug: 'pro',
375+
avatarUrl: '',
376+
publiclyVisible: true,
377+
isDefault: true,
378+
isRecurring: true,
379+
hasBaseFee: false,
380+
forPayerType: 'user',
381+
freeTrialDays: 7,
382+
freeTrialEnabled: true,
383+
},
384+
paymentMethod: undefined,
385+
confirm: vi.fn(),
386+
freeTrialEndsAt: null,
387+
needsPaymentMethod: false,
388+
} as any);
389+
390+
const { getByRole, getByText } = render(
391+
<Drawer.Root
392+
open
393+
onOpenChange={() => {}}
394+
>
395+
<Checkout
396+
planId='plan_credits'
397+
planPeriod='month'
398+
/>
399+
</Drawer.Root>,
400+
{ wrapper },
401+
);
402+
403+
await waitFor(() => {
404+
expect(getByRole('heading', { name: 'Checkout' })).toBeVisible();
405+
});
406+
407+
const prorationCreditRow = getByText('Credit for the remainder of your current subscription.').closest(
408+
'.cl-lineItemsGroup',
409+
);
410+
const accountCreditRow = getByText('Credit from account balance.').closest('.cl-lineItemsGroup');
411+
412+
expect(prorationCreditRow).toBeInTheDocument();
413+
expect(accountCreditRow).toBeInTheDocument();
414+
415+
expect(prorationCreditRow).toHaveTextContent('- $5.00');
416+
expect(accountCreditRow).toHaveTextContent('- $10.00');
417+
});
418+
312419
it('renders free trial details during confirmation stage', async () => {
313420
const { wrapper, fixtures } = await createFixtures(f => {
314421
f.withUser({ email_addresses: ['test@clerk.com'] });

packages/clerk-js/src/utils/billing.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type {
22
BillingCheckoutTotals,
33
BillingCheckoutTotalsJSON,
4+
BillingCredits,
5+
BillingCreditsJSON,
46
BillingMoneyAmount,
57
BillingMoneyAmountJSON,
68
BillingStatementTotals,
@@ -16,6 +18,26 @@ export const billingMoneyAmountFromJSON = (data: BillingMoneyAmountJSON): Billin
1618
};
1719
};
1820

21+
const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits => {
22+
return {
23+
proration: data.proration
24+
? {
25+
amount: billingMoneyAmountFromJSON(data.proration.amount),
26+
cycleDaysRemaining: data.proration.cycle_days_remaining,
27+
cycleDaysTotal: data.proration.cycle_days_total,
28+
cycleRemainingPercent: data.proration.cycle_remaining_percent,
29+
}
30+
: null,
31+
payer: data.payer
32+
? {
33+
remainingBalance: billingMoneyAmountFromJSON(data.payer.remaining_balance),
34+
appliedAmount: billingMoneyAmountFromJSON(data.payer.applied_amount),
35+
}
36+
: null,
37+
total: billingMoneyAmountFromJSON(data.total),
38+
};
39+
};
40+
1941
export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | BillingCheckoutTotalsJSON>(
2042
data: T,
2143
): T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals => {
@@ -31,7 +53,9 @@ export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | Bil
3153
if ('credit' in data) {
3254
totals.credit = data.credit ? billingMoneyAmountFromJSON(data.credit) : null;
3355
}
34-
56+
if ('credits' in data) {
57+
totals.credits = data.credits ? billingCreditsFromJSON(data.credits) : null;
58+
}
3559
if ('total_due_now' in data) {
3660
totals.totalDueNow = billingMoneyAmountFromJSON(data.total_due_now);
3761
}

packages/localizations/src/en-US.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export const enUS: LocalizationResource = {
110110
},
111111
credit: 'Credit',
112112
creditRemainder: 'Credit for the remainder of your current subscription.',
113+
payerCreditRemainder: 'Credit from account balance.',
113114
defaultFreePlanActive: "You're currently on the Free plan",
114115
free: 'Free',
115116
getStarted: 'Get started',

packages/shared/src/react/__tests__/payment-element.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ describe('PaymentElement Localization', () => {
128128
totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
129129
totalDueAfterFreeTrial: null,
130130
credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
131+
credits: {
132+
proration: null,
133+
payer: null,
134+
total: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
135+
},
131136
pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
132137
},
133138
status: 'needs_confirmation' as const,

packages/shared/src/types/billing.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,24 @@ export interface BillingMoneyAmount {
645645
currencySymbol: string;
646646
}
647647

648+
export interface BillingProrationCreditDetail {
649+
amount: BillingMoneyAmount;
650+
cycleDaysRemaining: number;
651+
cycleDaysTotal: number;
652+
cycleRemainingPercent: number;
653+
}
654+
655+
export interface BillingPayerCredit {
656+
remainingBalance: BillingMoneyAmount;
657+
appliedAmount: BillingMoneyAmount;
658+
}
659+
660+
export interface BillingCredits {
661+
proration: BillingProrationCreditDetail | null;
662+
payer: BillingPayerCredit | null;
663+
total: BillingMoneyAmount;
664+
}
665+
648666
/**
649667
* The `BillingCheckoutTotals` type represents the total costs, taxes, and other pricing details for a checkout session.
650668
*
@@ -671,6 +689,7 @@ export interface BillingCheckoutTotals {
671689
* Any credits (like account balance or promo credits) that are being applied to the checkout.
672690
*/
673691
credit: BillingMoneyAmount | null;
692+
credits: BillingCredits | null;
674693
/**
675694
* Any outstanding amount from previous unpaid invoices that is being collected as part of the checkout.
676695
*/

packages/shared/src/types/json.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON {
730730
credit?: {
731731
amount: BillingMoneyAmountJSON;
732732
};
733+
credits?: BillingCreditsJSON;
733734
plan: BillingPlanJSON;
734735
plan_period: BillingSubscriptionPlanPeriod;
735736
status: BillingSubscriptionStatus;
@@ -779,6 +780,33 @@ export interface BillingMoneyAmountJSON {
779780
currency_symbol: string;
780781
}
781782

783+
/**
784+
* Contains proration credit details including billing cycle information.
785+
*/
786+
export interface BillingProrationCreditDetailJSON {
787+
amount: BillingMoneyAmountJSON;
788+
cycle_days_remaining: number;
789+
cycle_days_total: number;
790+
cycle_remaining_percent: number;
791+
}
792+
793+
/**
794+
* Contains payer credit details including the available balance and the amount applied to this checkout.
795+
*/
796+
export interface BillingPayerCreditJSON {
797+
remaining_balance: BillingMoneyAmountJSON;
798+
applied_amount: BillingMoneyAmountJSON;
799+
}
800+
801+
/**
802+
* Unified credits breakdown for checkout totals. Can be used instead of `credit` field.
803+
*/
804+
export interface BillingCreditsJSON {
805+
proration: BillingProrationCreditDetailJSON | null;
806+
payer: BillingPayerCreditJSON | null;
807+
total: BillingMoneyAmountJSON;
808+
}
809+
782810
/**
783811
* @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.
784812
*/
@@ -788,6 +816,8 @@ export interface BillingCheckoutTotalsJSON {
788816
tax_total: BillingMoneyAmountJSON;
789817
total_due_now: BillingMoneyAmountJSON;
790818
credit: BillingMoneyAmountJSON | null;
819+
credits: BillingCreditsJSON | null;
820+
account_credit: BillingMoneyAmountJSON | null;
791821
past_due: BillingMoneyAmountJSON | null;
792822
total_due_after_free_trial: BillingMoneyAmountJSON | null;
793823
}

packages/shared/src/types/localization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export type __internal_LocalizationResource = {
207207
subtotal: LocalizationValue;
208208
credit: LocalizationValue;
209209
creditRemainder: LocalizationValue;
210+
payerCreditRemainder: LocalizationValue;
210211
totalDue: LocalizationValue;
211212
totalDueToday: LocalizationValue;
212213
pastDue: LocalizationValue;

0 commit comments

Comments
 (0)