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
17 changes: 17 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
'@clerk/msw': patch
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
'@clerk/msw': patch

this is an internal-only package, so we don't need to bump it

---

Update checkout totals to support the new `totals` shape from the API:

- Add `discounts` to `BillingCheckoutTotals` / `BillingCheckoutTotalsJSON`. It exposes a `proration` (a `BillingProrationDiscountDetail` with `amount`, `cycleDaysPassed`, `cycleDaysTotal`, `cyclePassedPercent`) describing the prorated discount applied when adding seats mid-billing-period — i.e. the part of the cycle that has already passed and is not charged — plus a `total` of all discounts.
- Add `totalsDuePerPeriod` / `totals_due_per_period` (`BillingPerPeriodTotals`) with `subtotal`, `baseFee`, `taxTotal`, `grandTotal`, and `perUnitTotals`: the full renewal charge breakdown covering all seats and the base plan fee, as opposed to the top-level totals which now only cover what is actively being purchased in the current checkout.
- Add `totalDuePerPeriod` / `total_due_per_period` (money amount) for backwards compatibility.
- Add `baseFee` / `base_fee` to the top-level totals.
- Parse all new fields in `billingTotalsFromJSON`.
- Render the prorated discount line item in `CheckoutForm` when present.
- Render a renewal subtotal and renewal total section in `CheckoutForm` when `totalsDuePerPeriod` is present.
5 changes: 5 additions & 0 deletions .changeset/payment-attempt-subtotal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the `Subtotal` row in the payment attempt details view to read from `paymentAttempt.totals.subtotal` so it matches the value shown in the Clerk Dashboard. Previously it rendered `subscriptionItem.amount` which caused the displayed subtotal to disagree with the dashboard.
46 changes: 43 additions & 3 deletions packages/clerk-js/src/utils/billing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
BillingCheckoutDiscounts,
BillingCheckoutDiscountsJSON,
BillingCheckoutTotals,
BillingCheckoutTotalsJSON,
BillingCredits,
Expand All @@ -7,6 +9,8 @@ import type {
BillingMoneyAmountJSON,
BillingPaymentTotals,
BillingPaymentTotalsJSON,
BillingPerPeriodTotals,
BillingPerPeriodTotalsJSON,
BillingPerUnitTotal,
BillingPerUnitTotalJSON,
BillingPlanUnitPriceJSON,
Expand Down Expand Up @@ -76,6 +80,30 @@ export const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits
};
};

const billingCheckoutDiscountsFromJSON = (data: BillingCheckoutDiscountsJSON): BillingCheckoutDiscounts => {
return {
proration: data.proration
? {
amount: billingMoneyAmountFromJSON(data.proration.amount),
cycleDaysPassed: data.proration.cycle_days_passed,
cycleDaysTotal: data.proration.cycle_days_total,
cyclePassedPercent: data.proration.cycle_passed_percent,
}
: null,
total: billingMoneyAmountFromJSON(data.total),
};
};

const billingPerPeriodTotalsFromJSON = (data: BillingPerPeriodTotalsJSON): BillingPerPeriodTotals => {
return {
subtotal: billingMoneyAmountFromJSON(data.subtotal),
baseFee: billingMoneyAmountFromJSON(data.base_fee),
taxTotal: billingMoneyAmountFromJSON(data.tax_total),
grandTotal: billingMoneyAmountFromJSON(data.grand_total),
perUnitTotals: data.per_unit_totals ? billingPerUnitTotalsFromJSON(data.per_unit_totals) : undefined,
};
};

export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | BillingCheckoutTotalsJSON>(
data: T,
): T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals => {
Expand All @@ -85,6 +113,9 @@ export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | Bil
taxTotal: billingMoneyAmountFromJSON(data.tax_total),
};

if ('base_fee' in data && data.base_fee) {
totals.baseFee = billingMoneyAmountFromJSON(data.base_fee);
}
if ('past_due' in data) {
totals.pastDue = data.past_due ? billingMoneyAmountFromJSON(data.past_due) : null;
}
Expand All @@ -101,15 +132,24 @@ export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | Bil
if ('total_due_now' in data) {
totals.totalDueNow = billingMoneyAmountFromJSON(data.total_due_now);
}
if ('total_due_per_period' in data) {
totals.totalDuePerPeriod = billingMoneyAmountFromJSON(data.total_due_per_period);
}

if ('total_due_after_free_trial' in data) {
totals.totalDueAfterFreeTrial = data.total_due_after_free_trial
? billingMoneyAmountFromJSON(data.total_due_after_free_trial)
: null;
}

if ('discounts' in data) {
totals.discounts = data.discounts ? billingCheckoutDiscountsFromJSON(data.discounts) : null;
}

if ('total_due_per_period' in data && data.total_due_per_period) {
totals.totalDuePerPeriod = billingMoneyAmountFromJSON(data.total_due_per_period);
}

if ('totals_due_per_period' in data && data.totals_due_per_period) {
totals.totalsDuePerPeriod = billingPerPeriodTotalsFromJSON(data.totals_due_per_period);
}

return totals as T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals;
};
3 changes: 3 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const enUS: LocalizationResource = {
accountCredit: 'Account credit',
creditRemainder: 'Credit for the remainder of your current subscription.',
payerCreditRemainder: 'Credit from account balance.',
proratedDiscount: 'Prorated discount',
defaultFreePlanActive: "You're currently on the Free plan",
free: 'Free',
getStarted: 'Get started',
Expand Down Expand Up @@ -193,6 +194,8 @@ export const enUS: LocalizationResource = {
trialStartedOn: 'Trial started on',
},
subtotal: 'Subtotal',
subtotalRenewal: 'Subtotal per period',
totalDuePerPeriod: 'Total per period',
switchPlan: 'Switch to this plan',
switchToAnnual: 'Switch to annual',
switchToAnnualWithAnnualPrice: 'Switch to annual {{currency}}{{price}} / year',
Expand Down
16 changes: 13 additions & 3 deletions packages/msw/BillingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand Down Expand Up @@ -169,9 +169,9 @@ export class BillingService {
planPeriod: BillingSubscriptionPlanPeriod,
): BillingMoneyAmountJSON {
if (planPeriod === 'annual') {
return plan.annual_fee ?? plan.fee;
return plan.annual_fee ?? plan.fee ?? this.createMoney(0);
}
return plan.fee;
return plan.fee ?? this.createMoney(0);
}

static createSubscriptionItem(
Expand Down Expand Up @@ -338,12 +338,22 @@ export class BillingService {
const totals: BillingCheckoutTotalsWithOptionalAccountCredit = {
grand_total: amount,
subtotal: amount,
base_fee: amount,
tax_total: tax,
total_due_now: amount,
credit: null,
credits: null,
past_due: null,
total_due_after_free_trial: amount,
account_credit: null,
discounts: null,
total_due_per_period: amount,
totals_due_per_period: {
subtotal: amount,
base_fee: amount,
tax_total: tax,
grand_total: amount,
},
};
return totals;
}
Expand Down
71 changes: 69 additions & 2 deletions packages/shared/src/types/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,16 +860,72 @@ export interface BillingCredits {
total: BillingMoneyAmount;
}

/**
* Details about a prorated discount applied when adding a seat mid-cycle. The discount covers the part of the
* billing period that has already passed, so the payer is only charged for the time remaining in the cycle.
*
* @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.
*/
export interface BillingProrationDiscountDetail {
amount: BillingMoneyAmount;
cycleDaysPassed: number;
cycleDaysTotal: number;
cyclePassedPercent: number;
}

/**
* Discounts applied to the checkout, such as prorated discounts for mid-cycle seat additions.
*
* @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.
*/
export interface BillingCheckoutDiscounts {
/**
* The prorated discount for the part of the billing period that has already passed when adding a seat mid-cycle.
* Unlike the proration credit (which refunds the unused remainder of a plan you already paid for), this discount
* means you are not charged for the portion of the new seat's cycle that has already elapsed.
*/
proration: BillingProrationDiscountDetail | null;
/**
* The total of all discounts applied to the checkout.
*/
total: BillingMoneyAmount;
}

/**
* Per-period renewal totals, describing what the subscription renewal charge will look like after the current checkout.
* Unlike the top-level checkout totals (which only reflect the items actively being purchased),
* this object contains the full renewal breakdown including all seats and the base plan fee.
*
* @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.
*/
export interface BillingPerPeriodTotals {
subtotal: BillingMoneyAmount;
baseFee: BillingMoneyAmount;
taxTotal: BillingMoneyAmount;
grandTotal: BillingMoneyAmount;
/**
* Per-unit cost breakdown for the renewal period, covering all units purchased to date
* (not just the ones being added in this checkout).
*/
perUnitTotals?: BillingPerUnitTotal[];
}

/**
* The `BillingCheckoutTotals` type represents the total costs, taxes, and other pricing details for a checkout session.
*
* @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.
*/
export interface BillingCheckoutTotals {
/**
* The price of the items or Plan before taxes, credits, or discounts are applied.
* The price of items actively being purchased in this checkout, before taxes and discounts.
* When only adding seats mid-cycle, this reflects just the new seats and excludes the base plan fee and
* seats that were already paid for.
*/
subtotal: BillingMoneyAmount;
/**
* The base plan fee portion of the totals, before per-unit charges and adjustments.
*/
baseFee?: BillingMoneyAmount;
/**
* The total amount for the checkout, including taxes and after credits/discounts are applied. This is the final amount due.
*/
Expand All @@ -879,7 +935,8 @@ export interface BillingCheckoutTotals {
*/
taxTotal: BillingMoneyAmount;
/**
* Per-unit cost breakdown for this checkout (for example, seats).
* Per-unit cost breakdown for items actively being purchased in this checkout (for example, seats being added).
* When only adding seats mid-cycle, this only covers the seats being added, not seats already paid for.
*/
perUnitTotals?: BillingPerUnitTotal[];
/**
Expand All @@ -903,6 +960,16 @@ export interface BillingCheckoutTotals {
* The amount that becomes due after a free trial ends.
*/
totalDueAfterFreeTrial: BillingMoneyAmount | null;
/**
* Discounts applied to this checkout such as mid-cycle prorated seat discounts.
*/
discounts?: BillingCheckoutDiscounts | null;
/**
* Full renewal period totals after this checkout completes.
* Contains the complete breakdown of what the next recurring charge will look like,
* including all seats and the base plan fee.
*/
totalsDuePerPeriod?: BillingPerPeriodTotals;
}

/**
Expand Down
81 changes: 79 additions & 2 deletions packages/shared/src/types/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -876,24 +876,101 @@ export interface BillingCreditsJSON {
total: BillingMoneyAmountJSON;
}

/**
* Details about a prorated discount applied when adding a seat mid-cycle. The discount covers the part of the
* billing period that has already passed, so the payer is only charged for the time remaining in the cycle.
*/
export interface BillingProrationDiscountDetailJSON {
amount: BillingMoneyAmountJSON;
cycle_days_passed: number;
cycle_days_total: number;
cycle_passed_percent: number;
}

/**
* Discounts applied to the checkout, such as prorated discounts for mid-cycle seat additions.
*
* @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.
*/
export interface BillingCheckoutDiscountsJSON {
/**
* The prorated discount for the part of the billing period that has already passed when adding a seat mid-cycle.
* Unlike the proration credit (which refunds the unused remainder of a plan you already paid for), this discount
* means you are not charged for the portion of the new seat's cycle that has already elapsed.
*/
proration: BillingProrationDiscountDetailJSON | null;
/**
* The total of all discounts applied to the checkout.
*/
total: BillingMoneyAmountJSON;
}

/**
* Per-period renewal totals, describing what the subscription renewal charge will look like after the current checkout.
* Unlike the top-level checkout totals (which only reflect the items actively being purchased),
* this object contains the full renewal breakdown including all seats and the base plan fee.
*
* @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.
*/
export interface BillingPerPeriodTotalsJSON {
subtotal: BillingMoneyAmountJSON;
base_fee: BillingMoneyAmountJSON;
tax_total: BillingMoneyAmountJSON;
grand_total: BillingMoneyAmountJSON;
/**
* Per-unit cost breakdown for the renewal period, covering all units purchased to date
* (not just the ones being added in this checkout).
*/
per_unit_totals?: BillingPerUnitTotalJSON[];
}

/**
* @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.
*/
export interface BillingCheckoutTotalsJSON {
grand_total: BillingMoneyAmountJSON;
/**
* The price of items actively being purchased in this checkout, before taxes and discounts.
* When only adding seats mid-cycle, this reflects just the new seats and excludes the base plan fee and
* seats that were already paid for.
*/
subtotal: BillingMoneyAmountJSON;
/**
* The base plan fee portion of the totals, before per-unit charges and adjustments.
*/
base_fee: BillingMoneyAmountJSON;
tax_total: BillingMoneyAmountJSON;
/**
* Per-unit cost breakdown for this checkout (for example, seats).
* Per-unit cost breakdown for items actively being purchased in this checkout (for example, seats being added).
* When only adding seats mid-cycle, this only covers the seats being added, not seats already paid for.
* Omitted when the checkout is not seat-based.
*/
per_unit_totals?: BillingPerUnitTotalJSON[];
total_due_now: BillingMoneyAmountJSON;
total_due_per_period: BillingMoneyAmountJSON;
/**
* Legacy credit field. Kept for backwards compatibility; prefer the unified `credits` breakdown.
*/
credit: BillingMoneyAmountJSON | null;
credits: BillingCreditsJSON | null;
account_credit: BillingMoneyAmountJSON | null;
past_due: BillingMoneyAmountJSON | null;
total_due_after_free_trial: BillingMoneyAmountJSON | null;
/**
* Discounts applied to this checkout such as mid-cycle prorated seat discounts.
* The key is always present; the value is `null` when no discounts apply.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not sure we need this disclaimer since we don't have something similar elsewhere

*/
discounts: BillingCheckoutDiscountsJSON | null;
/**
* The expected recurring payment for each future billing period.
* Kept for backwards compatibility. Prefer `totals_due_per_period` for the full breakdown.
*/
total_due_per_period: BillingMoneyAmountJSON;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why are we adding a deprecated field? maybe we just expose totals_due_per_period?

/**
* Full renewal period totals after this checkout completes.
* Contains the complete breakdown of what the next recurring charge will look like,
* including all seats and the base plan fee.
*/
totals_due_per_period: BillingPerPeriodTotalsJSON;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,16 @@ export type __internal_LocalizationResource = {
viewPayment: LocalizationValue;
availableFeatures: LocalizationValue;
subtotal: LocalizationValue;
subtotalRenewal: LocalizationValue;
credit: LocalizationValue;
prorationCredit: LocalizationValue;
accountCredit: LocalizationValue;
creditRemainder: LocalizationValue;
payerCreditRemainder: LocalizationValue;
proratedDiscount: LocalizationValue;
totalDue: LocalizationValue;
totalDueToday: LocalizationValue;
totalDuePerPeriod: LocalizationValue;
pastDue: LocalizationValue;
pay: LocalizationValue<'amount'>;
cancelSubscriptionTitle: LocalizationValue<'plan'>;
Expand Down
Loading
Loading