Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/shy-loops-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Add support for account credits in checkout.
Comment on lines +1 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add tests covering account-credit checkout behavior.

No tests were added/updated for the new credits flow (parsing + UI display). Please add coverage to prevent regressions.
As per coding guidelines: "If there are no tests added or modified as part of the PR, please suggest that tests be added to cover the changes."

🤖 Prompt for AI Agents
In @.changeset/shy-loops-type.md around lines 1 - 8, Add unit and integration
tests covering the new account-credit checkout flow: write unit tests for the
parsing function (e.g., parseAccountCredits) to confirm it parses valid/invalid
payloads and edge cases, and add React Testing Library integration tests for the
Checkout/Cart component (e.g., Checkout, AccountCreditsBadge or
AccountCreditsDisplay) to assert the UI shows the credit amount, that totals are
recalculated when credits apply, and that error/zero-credit states render
correctly; mock the API/props that supply credits and include test cases for
applying credits, removing credits, and display formatting to prevent
regressions.

336 changes: 336 additions & 0 deletions packages/clerk-js/sandbox/scenarios/checkout-account-credit.ts
Original file line number Diff line number Diff line change
@@ -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',
};
}
1 change: 1 addition & 0 deletions packages/clerk-js/sandbox/scenarios/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { UserButtonSignedIn } from './user-button-signed-in';
export { CheckoutAccountCredit } from './checkout-account-credit';
26 changes: 25 additions & 1 deletion packages/clerk-js/src/utils/billing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
BillingCheckoutTotals,
BillingCheckoutTotalsJSON,
BillingCredits,
BillingCreditsJSON,
BillingMoneyAmount,
BillingMoneyAmountJSON,
BillingStatementTotals,
Expand All @@ -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 = <T extends BillingStatementTotalsJSON | BillingCheckoutTotalsJSON>(
data: T,
): T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals => {
Expand All @@ -31,7 +53,9 @@ export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | Bil
if ('credit' in data) {
totals.credit = data.credit ? billingMoneyAmountFromJSON(data.credit) : null;
}

if ('credits' in data) {
totals.credits = data.credits ? billingCreditsFromJSON(data.credits) : null;
}
if ('total_due_now' in data) {
totals.totalDueNow = billingMoneyAmountFromJSON(data.total_due_now);
}
Expand Down
Loading
Loading