Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(js): support new checkout totals shape by mauricioabreu · Pull Request #8727 · clerk/javascript · GitHub
Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(js): support new checkout totals shape by mauricioabreu · Pull Request #8727 · clerk/javascript · GitHub
Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(js): support new checkout totals shape by mauricioabreu · Pull Request #8727 · clerk/javascript · GitHub
Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(js): support new checkout totals shape by mauricioabreu · Pull Request #8727 · clerk/javascript · GitHub
Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(js): support new checkout totals shape by mauricioabreu · Pull Request #8727 · clerk/javascript · GitHub
Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(js): support new checkout totals shape by mauricioabreu · Pull Request #8727 · clerk/javascript · GitHub
Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(js): support new checkout totals shape by mauricioabreu · Pull Request #8727 · clerk/javascript · GitHub
Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/checkout-totals-new-shape.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,9 @@ import type {
BillingCheckoutTotalsJSON,
BillingInitializedPaymentMethodJSON,
BillingMoneyAmountJSON,
BillingPayerJSON,
BillingPaymentJSON,
BillingPaymentMethodJSON,
BillingPayerJSON,
BillingPlanJSON,
BillingStatementJSON,
BillingSubscriptionItemJSON,
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
80 changes: 78 additions & 2 deletions packages/shared/src/types/json.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -876,24 +876,100 @@ 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.
*/
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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

We are not actually adding it. it existed before my changes, it just moved from line 891 to 867.

/**
* 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 numberDiff line numberDiff 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