diff --git a/.changeset/tame-bats-tell.md b/.changeset/tame-bats-tell.md new file mode 100644 index 00000000000..0cedcf55619 --- /dev/null +++ b/.changeset/tame-bats-tell.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Update fields for BillingSubscription and BillingSubscriptionItem diff --git a/packages/backend/src/api/resources/CommerceSubscription.ts b/packages/backend/src/api/resources/CommerceSubscription.ts index e1332307b0b..4858f62a453 100644 --- a/packages/backend/src/api/resources/CommerceSubscription.ts +++ b/packages/backend/src/api/resources/CommerceSubscription.ts @@ -1,5 +1,6 @@ -import type { BillingMoneyAmount } from '@clerk/shared/types'; +import type { BillingMoneyAmount, BillingPerUnitTotal, BillingTotals } from '@clerk/shared/types'; +import { billingMoneyAmountFromJSON, billingPerUnitTotalsFromJSON, billingTotalsFromJSON } from '../../util/billing'; import { BillingSubscriptionItem } from './CommerceSubscriptionItem'; import type { BillingSubscriptionJSON } from './JSON'; @@ -12,6 +13,8 @@ export class BillingSubscription { constructor( /** The unique identifier for the Subscription. */ readonly id: string, + /** The ID of the instance this Subscription belongs to. */ + readonly instanceId: string, /** The current status of the Subscription. */ readonly status: BillingSubscriptionJSON['status'], /** The ID of the payer for this Subscription. */ @@ -32,6 +35,10 @@ export class BillingSubscription { date: number; /** The amount of the next payment. */ amount: BillingMoneyAmount; + /** The per-unit cost breakdown for the next payment. */ + perUnitTotals?: BillingPerUnitTotal[]; + /** The full cost breakdown for the next payment. */ + totals?: BillingTotals; } | null, /** Whether the payer is eligible for a free trial. */ readonly eligibleForFreeTrial: boolean, @@ -41,17 +48,17 @@ export class BillingSubscription { const nextPayment = data.next_payment ? { date: data.next_payment.date, - amount: { - amount: data.next_payment.amount.amount, - amountFormatted: data.next_payment.amount.amount_formatted, - currency: data.next_payment.amount.currency, - currencySymbol: data.next_payment.amount.currency_symbol, - }, + amount: billingMoneyAmountFromJSON(data.next_payment.amount), + perUnitTotals: data.next_payment.per_unit_totals + ? billingPerUnitTotalsFromJSON(data.next_payment.per_unit_totals) + : undefined, + totals: data.next_payment.totals ? billingTotalsFromJSON(data.next_payment.totals) : undefined, } : null; return new BillingSubscription( data.id, + data.instance_id, data.status, data.payer_id, data.created_at, diff --git a/packages/backend/src/api/resources/CommerceSubscriptionItem.ts b/packages/backend/src/api/resources/CommerceSubscriptionItem.ts index 79b0d8991bd..2bfe6bc3ad2 100644 --- a/packages/backend/src/api/resources/CommerceSubscriptionItem.ts +++ b/packages/backend/src/api/resources/CommerceSubscriptionItem.ts @@ -1,5 +1,16 @@ -import type { BillingMoneyAmount, BillingMoneyAmountJSON } from '@clerk/shared/types'; +import type { + BillingMoneyAmount, + BillingPerUnitTotal, + BillingSubscriptionItemSeats, + BillingTotals, +} from '@clerk/shared/types'; +import { + billingMoneyAmountFromJSON, + billingPerUnitTotalsFromJSON, + billingSubscriptionItemSeatsFromJSON, + billingTotalsFromJSON, +} from '../../util/billing'; import { BillingPlan } from './CommercePlan'; import type { BillingSubscriptionItemJSON } from './JSON'; @@ -12,6 +23,8 @@ export class BillingSubscriptionItem { constructor( /** The unique identifier for the Subscription Item. */ readonly id: string, + /** The ID of the instance this Subscription Item belongs to. */ + readonly instanceId: string, /** The status of the Subscription Item. */ readonly status: BillingSubscriptionItemJSON['status'], /** The period of the Plan associated with this Subscription Item. */ @@ -22,9 +35,13 @@ export class BillingSubscriptionItem { readonly nextPayment: | { /** The amount of the next payment. */ - amount: number; + amount: BillingMoneyAmount; /** The Unix timestamp (milliseconds) of when the next payment is scheduled. */ date: number; + /** The per-unit cost breakdown for the next payment. */ + perUnitTotals?: BillingPerUnitTotal[]; + /** The full cost breakdown for the next payment. */ + totals?: BillingTotals; } | null | undefined, @@ -34,6 +51,8 @@ export class BillingSubscriptionItem { readonly plan: BillingPlan | null, /** The ID of the Plan associated with this Subscription Item. */ readonly planId: string | null, + /** The ID of the price associated with this Subscription Item. */ + readonly priceId: string | null, /** The Unix timestamp (milliseconds) of when the Subscription Item was created. */ readonly createdAt: number, /** The Unix timestamp (milliseconds) of when the Subscription Item was last updated. */ @@ -49,36 +68,36 @@ export class BillingSubscriptionItem { /** The ID of the payer for this Subscription Item. */ readonly payerId: string | undefined, /** Whether this Subscription Item is currently in a free trial period. */ - readonly isFreeTrial?: boolean, + readonly isFreeTrial: boolean, /** The lifetime amount paid for this Subscription Item. */ readonly lifetimePaid?: BillingMoneyAmount, + /** Seat entitlement details for organization subscription items with seat-based billing. */ + readonly seats?: BillingSubscriptionItemSeats, ) {} static fromJSON(data: BillingSubscriptionItemJSON): BillingSubscriptionItem { - function formatAmountJSON( - amount: BillingMoneyAmountJSON | null | undefined, - ): BillingMoneyAmount | null | undefined { - if (!amount) { - return amount; - } - - return { - amount: amount.amount, - amountFormatted: amount.amount_formatted, - currency: amount.currency, - currencySymbol: amount.currency_symbol, - }; - } + const nextPayment = data.next_payment + ? { + amount: billingMoneyAmountFromJSON(data.next_payment.amount), + date: data.next_payment.date, + perUnitTotals: data.next_payment.per_unit_totals + ? billingPerUnitTotalsFromJSON(data.next_payment.per_unit_totals) + : undefined, + totals: data.next_payment.totals ? billingTotalsFromJSON(data.next_payment.totals) : undefined, + } + : data.next_payment; return new BillingSubscriptionItem( data.id, + data.instance_id, data.status, data.plan_period, data.period_start, - data.next_payment, - formatAmountJSON(data.amount) ?? undefined, + nextPayment, + data.amount ? billingMoneyAmountFromJSON(data.amount) : undefined, data.plan ? BillingPlan.fromJSON(data.plan) : null, data.plan_id ?? null, + data.price_id ?? null, data.created_at, data.updated_at, data.period_end, @@ -87,7 +106,8 @@ export class BillingSubscriptionItem { data.ended_at, data.payer_id, data.is_free_trial, - formatAmountJSON(data.lifetime_paid) ?? undefined, + data.lifetime_paid ? billingMoneyAmountFromJSON(data.lifetime_paid) : undefined, + data.seats ? billingSubscriptionItemSeatsFromJSON(data.seats) : undefined, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 298c4983d0a..086c8c68f23 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -1,4 +1,12 @@ -import type { LastAuthenticationStrategy, SignUpStatus, VerificationStatus } from '@clerk/shared/types'; +import type { + BillingPerUnitTotalJSON, + BillingSubscriptionItemNextPaymentJSON, + BillingSubscriptionItemSeatsJSON, + BillingTotalsJSON as SharedBillingTotalsJSON, + LastAuthenticationStrategy, + SignUpStatus, + VerificationStatus, +} from '@clerk/shared/types'; import type { ActorTokenStatus, @@ -1006,25 +1014,25 @@ type BillingSubscriptionItemStatus = */ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { object: typeof ObjectType.BillingSubscriptionItem; + instance_id: string; status: BillingSubscriptionItemStatus; plan_period: 'month' | 'annual'; payer_id?: string; + price_id?: string; period_start: number; period_end: number | null; - is_free_trial?: boolean; + is_free_trial: boolean; ended_at: number | null; created_at: number; updated_at: number; canceled_at: number | null; past_due_at: number | null; - lifetime_paid: BillingMoneyAmountJSON | null; - next_payment?: { - amount: number; - date: number; - } | null; - amount: BillingMoneyAmountJSON; + lifetime_paid?: BillingMoneyAmountJSON | null; + next_payment?: BillingSubscriptionItemNextPaymentJSON | null; + amount?: BillingMoneyAmountJSON | null; plan?: BillingPlanJSON | null; plan_id?: string | null; + seats?: BillingSubscriptionItemSeatsJSON; } /** @@ -1127,6 +1135,7 @@ export interface BillingSubscriptionWebhookEventJSON extends ClerkResourceJSON { export interface BillingSubscriptionJSON extends ClerkResourceJSON { object: typeof ObjectType.BillingSubscription; + instance_id: string; status: 'active' | 'past_due' | 'canceled' | 'ended' | 'abandoned' | 'incomplete'; payer_id: string; created_at: number; @@ -1137,6 +1146,8 @@ export interface BillingSubscriptionJSON extends ClerkResourceJSON { next_payment?: { date: number; amount: BillingMoneyAmountJSON; + per_unit_totals?: BillingPerUnitTotalJSON[]; + totals?: SharedBillingTotalsJSON; }; eligible_for_free_trial?: boolean; } diff --git a/packages/backend/src/util/billing.ts b/packages/backend/src/util/billing.ts new file mode 100644 index 00000000000..ab22640d0c4 --- /dev/null +++ b/packages/backend/src/util/billing.ts @@ -0,0 +1,105 @@ +import type { + BillingCredits, + BillingCreditsJSON, + BillingDiscounts, + BillingDiscountsJSON, + BillingMoneyAmount, + BillingMoneyAmountJSON, + BillingPeriodTotals, + BillingPeriodTotalsJSON, + BillingPerUnitTotal, + BillingPerUnitTotalJSON, + BillingSubscriptionItemSeats, + BillingSubscriptionItemSeatsJSON, + BillingTotals, + BillingTotalsJSON, +} from '@clerk/shared/types'; + +export const billingMoneyAmountFromJSON = (amount: BillingMoneyAmountJSON): BillingMoneyAmount => ({ + amount: amount.amount, + amountFormatted: amount.amount_formatted, + currency: amount.currency, + currencySymbol: amount.currency_symbol, +}); + +export const billingPerUnitTotalsFromJSON = (perUnitTotals: BillingPerUnitTotalJSON[]): BillingPerUnitTotal[] => + perUnitTotals.map(perUnitTotal => ({ + name: perUnitTotal.name, + blockSize: perUnitTotal.block_size, + tiers: perUnitTotal.tiers.map(tier => ({ + quantity: tier.quantity, + feePerBlock: billingMoneyAmountFromJSON(tier.fee_per_block), + total: billingMoneyAmountFromJSON(tier.total), + })), + })); + +const billingCreditsFromJSON = (credits: BillingCreditsJSON): BillingCredits => ({ + proration: credits.proration + ? { + amount: billingMoneyAmountFromJSON(credits.proration.amount), + cycleDaysRemaining: credits.proration.cycle_days_remaining, + cycleDaysTotal: credits.proration.cycle_days_total, + cycleRemainingPercent: credits.proration.cycle_remaining_percent, + } + : null, + payer: credits.payer + ? { + remainingBalance: billingMoneyAmountFromJSON(credits.payer.remaining_balance), + appliedAmount: billingMoneyAmountFromJSON(credits.payer.applied_amount), + } + : null, + total: billingMoneyAmountFromJSON(credits.total), +}); + +const billingDiscountsFromJSON = (discounts: BillingDiscountsJSON): BillingDiscounts => ({ + proration: discounts.proration + ? { + amount: billingMoneyAmountFromJSON(discounts.proration.amount), + cycleDaysPassed: discounts.proration.cycle_days_passed, + cycleDaysTotal: discounts.proration.cycle_days_total, + cyclePassedPercent: discounts.proration.cycle_passed_percent, + } + : null, + total: billingMoneyAmountFromJSON(discounts.total), +}); + +const billingPeriodTotalsFromJSON = (totals: BillingPeriodTotalsJSON): BillingPeriodTotals => ({ + subtotal: billingMoneyAmountFromJSON(totals.subtotal), + baseFee: billingMoneyAmountFromJSON(totals.base_fee), + taxTotal: billingMoneyAmountFromJSON(totals.tax_total), + grandTotal: billingMoneyAmountFromJSON(totals.grand_total), + perUnitTotals: totals.per_unit_totals ? billingPerUnitTotalsFromJSON(totals.per_unit_totals) : undefined, +}); + +export const billingTotalsFromJSON = (totals: BillingTotalsJSON): BillingTotals => ({ + subtotal: billingMoneyAmountFromJSON(totals.subtotal), + baseFee: totals.base_fee ? billingMoneyAmountFromJSON(totals.base_fee) : null, + taxTotal: billingMoneyAmountFromJSON(totals.tax_total), + grandTotal: billingMoneyAmountFromJSON(totals.grand_total), + totalDueAfterFreeTrial: totals.total_due_after_free_trial + ? billingMoneyAmountFromJSON(totals.total_due_after_free_trial) + : totals.total_due_after_free_trial, + credit: totals.credit ? billingMoneyAmountFromJSON(totals.credit) : totals.credit, + credits: totals.credits ? billingCreditsFromJSON(totals.credits) : null, + discounts: totals.discounts ? billingDiscountsFromJSON(totals.discounts) : null, + pastDue: totals.past_due ? billingMoneyAmountFromJSON(totals.past_due) : totals.past_due, + totalDueNow: totals.total_due_now ? billingMoneyAmountFromJSON(totals.total_due_now) : undefined, + perUnitTotals: totals.per_unit_totals ? billingPerUnitTotalsFromJSON(totals.per_unit_totals) : undefined, + totalsDuePerPeriod: totals.totals_due_per_period + ? billingPeriodTotalsFromJSON(totals.totals_due_per_period) + : undefined, + totalDuePerPeriod: totals.total_due_per_period ? billingMoneyAmountFromJSON(totals.total_due_per_period) : undefined, +}); + +export const billingSubscriptionItemSeatsFromJSON = ( + seats: BillingSubscriptionItemSeatsJSON, +): BillingSubscriptionItemSeats => ({ + quantity: seats.quantity, + tiers: seats.tiers + ? seats.tiers.map(tier => ({ + quantity: tier.quantity, + feePerBlock: billingMoneyAmountFromJSON(tier.fee_per_block), + total: billingMoneyAmountFromJSON(tier.total), + })) + : undefined, +});