diff --git a/server/src/internal/balances/autoTopUp/compute/computeAutoTopupPlan.ts b/server/src/internal/balances/autoTopUp/compute/computeAutoTopupPlan.ts index 603dcda85..015b2168c 100644 --- a/server/src/internal/balances/autoTopUp/compute/computeAutoTopupPlan.ts +++ b/server/src/internal/balances/autoTopUp/compute/computeAutoTopupPlan.ts @@ -17,8 +17,9 @@ import { buildUpdatedOptions, updateCusEntOptionsInline, } from "../helpers/autoTopUpUtils.js"; +import { computeRebalancedAutoTopUp } from "./computeRebalancedAutoTopUp.js"; -/** Compute the auto top-up billing plan + stripe invoice action. Returns null if line item amount is <= 0. */ +/** Compute the auto top-up billing plan + stripe invoice action. Throws if line item amount is <= 0. */ export const computeAutoTopupPlan = ({ ctx, autoTopupContext, @@ -72,17 +73,24 @@ export const computeAutoTopupPlan = ({ }); } - // C. Build autumn billing plan + // C. Compute paydown + prepaid remainder deltas from the context's FullCustomer. + // Deltas apply atomically at execute time via `balance + delta` SQL increments. + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer: autoTopupContext.fullCustomer, + featureId: feature.id, + quantity, + prepaidCustomerEntitlementId: customerEntitlement.id, + }); + + // D. Build autumn billing plan. `options.quantity` bumps by the FULL topUpPacks + // because the customer is charged for the full purchase regardless of where the + // balance landed. const autumnBillingPlan: AutumnBillingPlan = { customerId: autoTopupContext.fullCustomer?.id ?? "", insertCustomerProducts: [], lineItems: [lineItem], - updateCustomerEntitlements: [ - { - customerEntitlement, - balanceChange: quantity, - }, - ], + updateCustomerEntitlements: [], + autoTopupRebalance: { deltas }, updateCustomerProduct: { customerProduct: cusProduct, updates: { diff --git a/server/src/internal/balances/autoTopUp/compute/computeRebalancedAutoTopUp.ts b/server/src/internal/balances/autoTopUp/compute/computeRebalancedAutoTopUp.ts new file mode 100644 index 000000000..35a9c95c9 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/compute/computeRebalancedAutoTopUp.ts @@ -0,0 +1,121 @@ +import { + type FullCusEntWithFullCusProduct, + type FullCustomer, + fullCustomerToCustomerEntitlements, + isBooleanCusEnt, + isEntityScopedCusEnt, + isUnlimitedCusEnt, +} from "@autumn/shared"; +import { runDeductionPass } from "@/internal/balances/track/deductUtils/deductFromCusEntsTypescript.js"; +import type { DeductionUpdates } from "@/internal/balances/utils/types/deductionUpdate.js"; +import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js"; + +export type AutoTopupRebalanceDelta = { + cusEntId: string; + featureId: string; + delta: number; +}; + +/** + * Sort order for paydown — mirrors deductFromCusEntsTypescript's pass-2 sort so overage + * heals on the cusEnts that accrued it first (`usage_allowed: true`), with a stable + * `created_at` tiebreaker. + */ +const sortForPaydown = ( + cusEnts: FullCusEntWithFullCusProduct[], +): FullCusEntWithFullCusProduct[] => { + return [...cusEnts].sort((a, b) => { + const leftUsageAllowed = a.usage_allowed ?? false; + const rightUsageAllowed = b.usage_allowed ?? false; + + if (leftUsageAllowed !== rightUsageAllowed) { + return leftUsageAllowed ? -1 : 1; + } + + return (a.created_at ?? 0) - (b.created_at ?? 0); + }); +}; + +const isPaydownCandidate = (cusEnt: FullCusEntWithFullCusProduct): boolean => { + if (isBooleanCusEnt({ cusEnt })) return false; + if (isUnlimitedCusEnt(cusEnt)) return false; + if (isEntityScopedCusEnt(cusEnt)) return false; + return true; +}; + +const hasTopLevelOverage = (cusEnt: FullCusEntWithFullCusProduct): boolean => + (cusEnt.balance ?? 0) < 0; + +/** + * Compute the list of balance deltas needed to rebalance an auto top-up: + * 1. Pay down overage on non-prepaid, non-entity-scoped top-level cusEnts first + * (capped at 0 per cusEnt — the paydown primitive). + * 2. Route the remainder to the prepaid one-off cusEnt. + * + * Deltas are applied at execute time via atomic SQL balance + delta increments, so + * they're race-safe against concurrent deductions. Entity-scoped cusEnts are excluded + * because there's no per-entity atomic primitive today (future work). + */ +export const computeRebalancedAutoTopUp = ({ + fullCustomer, + featureId, + quantity, + prepaidCustomerEntitlementId, +}: { + fullCustomer: FullCustomer; + featureId: string; + quantity: number; + prepaidCustomerEntitlementId: string; +}): { deltas: AutoTopupRebalanceDelta[] } => { + if (quantity <= 0) return { deltas: [] }; + + const cusEntsForFeature = fullCustomerToCustomerEntitlements({ + fullCustomer, + featureId, + }); + + const prepaidCusEnt = cusEntsForFeature.find( + (cusEnt) => cusEnt.id === prepaidCustomerEntitlementId, + ); + + if (!prepaidCusEnt) return { deltas: [] }; + + const candidates = cusEntsForFeature.filter( + (cusEnt) => + cusEnt.id !== prepaidCustomerEntitlementId && + isPaydownCandidate(cusEnt) && + hasTopLevelOverage(cusEnt), + ); + + const deltas: AutoTopupRebalanceDelta[] = []; + let remainder = quantity; + + if (candidates.length > 0) { + const sortedCandidates = sortForPaydown(candidates); + + const updates: DeductionUpdates = {}; + const mutationLogs: MutationLogItem[] = []; + + const passResult = runDeductionPass({ + cusEnts: sortedCandidates, + amountToDeduct: -quantity, + maxBalance: 0, + updates, + mutationLogs, + }); + + remainder = Math.abs(passResult.amountToDeduct); + + for (const [cusEntId, update] of Object.entries(updates)) { + const delta = -update.deducted; + if (delta === 0) continue; + deltas.push({ cusEntId, featureId, delta }); + } + } + + if (remainder > 0) { + deltas.push({ cusEntId: prepaidCusEnt.id, featureId, delta: remainder }); + } + + return { deltas }; +}; diff --git a/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts b/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts index 43b1ba53f..ba47be7c1 100644 --- a/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts +++ b/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts @@ -27,10 +27,6 @@ const getAutoTopupFullCustomer = async ({ ctx: AutumnContext; customerId: string; }): Promise => { - // console.log(`GETTING AUTO TOP UP CUSTOMER ${customerId}`); - // console.log( - // `IS FULL SUBJECT ROLLOUT ENABLED: ${isFullSubjectRolloutEnabled({ ctx })}`, - // ); if (isFullSubjectRolloutEnabled({ ctx })) { const { fullSubject: cachedFullSubject } = await getCachedFullSubject({ ctx, @@ -68,7 +64,6 @@ const getAutoTopupFullCustomer = async ({ let fullCustomer = await getCachedFullCustomer({ ctx, customerId }); if (!fullCustomer) { - console.log(`NO CACHED FULL CUSTOMER, FETCHING FROM DB`); fullCustomer = await CusService.getFull({ ctx, idOrInternalId: customerId, diff --git a/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts b/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts index ca9c84273..db5877326 100644 --- a/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts +++ b/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts @@ -93,8 +93,12 @@ const buildMutationLogs = ({ /** * Runs a single deduction pass over customer entitlements, tracking updates and mutation logs. * Extracted to avoid duplicating the per-cusEnt loop logic across passes. + * + * Also exported for reuse by callers that need the "paydown primitive" — e.g. the auto + * top-up rebalancer uses a single pass with `maxBalance: 0` and a negative `amountToDeduct` + * to heal overage'd cusEnts up to (but not past) zero. */ -const runDeductionPass = ({ +export const runDeductionPass = ({ cusEnts, amountToDeduct, targetEntityId, diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance.ts new file mode 100644 index 000000000..34c47294e --- /dev/null +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance.ts @@ -0,0 +1,33 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { AutoTopupRebalanceDelta } from "@/internal/balances/autoTopUp/compute/computeRebalancedAutoTopUp.js"; +import { customerEntitlementActions } from "@/internal/customers/cusProducts/cusEnts/actions/index.js"; + +/** + * Apply pre-computed auto top-up rebalance deltas. Each delta is an atomic SQL + * `balance + delta` increment (+ Redis JSON.NUMINCRBY), so concurrent deductions + * between compute and execute are preserved. + * + * The deltas themselves are computed earlier by `computeRebalancedAutoTopUp` from + * the context's FullCustomer snapshot; this executor step is purely mechanical. + */ +export const executeAutoTopupRebalance = async ({ + ctx, + customerId, + deltas, +}: { + ctx: AutumnContext; + customerId: string; + deltas: AutoTopupRebalanceDelta[]; +}): Promise => { + for (const { cusEntId, featureId, delta } of deltas) { + if (delta === 0) continue; + + await customerEntitlementActions.adjustBalanceDbAndCache({ + ctx, + customerId, + cusEntId, + featureId, + delta, + }); + } +}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index b5c83b205..bf5976a63 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -1,6 +1,7 @@ import type { AutumnBillingPlan, Invoice } from "@autumn/shared"; import type Stripe from "stripe"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { executeAutoTopupRebalance } from "@/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance"; import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts"; import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements"; import { @@ -114,6 +115,16 @@ export const executeAutumnBillingPlan = async ({ updates: autumnBillingPlan.updateCustomerEntitlements, }); + // 5a. Auto top-up rebalance: apply pre-computed paydown + remainder deltas as + // atomic SQL `balance + delta` increments. + if (autumnBillingPlan.autoTopupRebalance) { + await executeAutoTopupRebalance({ + ctx, + customerId: autumnBillingPlan.customerId, + deltas: autumnBillingPlan.autoTopupRebalance.deltas, + }); + } + // 6. Upsert subscription (if provided) if (autumnBillingPlan.upsertSubscription) { await SubService.upsertByStripeId({ diff --git a/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts b/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts index f5d15b4d0..a61af94a3 100644 --- a/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts @@ -64,6 +64,15 @@ export const logAutumnBillingPlan = ({ item: `${item.description}: ${item.amountAfterDiscounts}`, effectivePeriod: `${formatMs(item.context.effectivePeriod?.start)} - ${formatMs(item.context.effectivePeriod?.end)}`, })) ?? "none", + + autoTopupRebalance: plan.autoTopupRebalance + ? plan.autoTopupRebalance.deltas + .map( + ({ cusEntId, delta }) => + `${cusEntId}: ${delta > 0 ? "+" : ""}${delta}`, + ) + .join(", ") || "no-op" + : "none", }, }, }); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-rebalance.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-rebalance.test.ts new file mode 100644 index 000000000..72bf30f67 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-rebalance.test.ts @@ -0,0 +1,286 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { setCustomerOverageAllowed } from "@tests/integration/balances/utils/overage-allowed-utils/customerOverageAllowedUtils.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectCustomerProductOptions } from "@tests/integration/utils/expectCustomerProductOptions"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { makeAutoTopupConfig } from "./utils/makeAutoTopupConfig.js"; + +/** Wait time for SQS auto top-up processing */ +const AUTO_TOPUP_WAIT_MS = 40000; + +/** + * ATU Rebalance: verifies that auto top-up quantities first pay down existing overage + * on non-prepaid, non-entity-scoped top-level cusEnts before routing the remainder to + * the one-off prepaid cusEnt. + * + * Architecture note: paydown computation runs at compute time from the billing + * context's FullCustomer snapshot. The billing plan carries pre-computed deltas + * (`autoTopupRebalance: { deltas: [{ cusEntId, featureId, delta }] }`), and execute + * applies them with race-safe atomic delta writes via `adjustBalanceDbAndCache`. + * + * Cache v2 sequencing note: `customers.update(billing_controls)` invalidates the + * FullSubject cache. If the prior usage deduction has not been synced to Postgres + * yet, the next read rehydrates from a stale DB and the post-track ATU trigger + * misses the overage. So we configure billing_controls FIRST, then drive usage, + * so the deduction itself triggers ATU against the cached, deducted state. + * + * Entity-scoped cusEnts are intentionally excluded from paydown — there is no + * race-safe per-entity atomic increment primitive today. Entity-scoped overage is + * left in place and the full top-up quantity flows to prepaid as remainder. Adding + * safe entity paydown is a separate follow-up that requires JSONB-path atomic updates. + * + * Each test attaches TWO products: + * - Base: `products.base` + `items.lifetimeMessages({ includedUsage })` — the + * overage'd cusEnt (`usage_allowed` after enabling overage) that will absorb + * paydown. + * - Top-up: `products.oneOffAddOn` + `items.oneOffMessages` — the one-off prepaid + * cusEnt that ATU targets. Starts at 0/0 when attached with `quantity: 0`. + */ + +test.concurrent( + `${chalk.yellowBright("auto-topup rebalance-1: paydown + remainder to prepaid")}`, + async () => { + const baseProd = products.base({ + id: "topup-rb1-base", + items: [items.lifetimeMessages({ includedUsage: 1000 })], + }); + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-rb1-addon", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1, ctx } = await initScenario({ + customerId: "auto-topup-rb1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [baseProd, oneOffProd] }), + ], + actions: [ + s.attach({ productId: baseProd.id }), + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 0 }], + }), + ], + }); + + // Allow the base cusEnt to go into overage. + await setCustomerOverageAllowed({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + enabled: true, + }); + + // Configure ATU FIRST (before any deduction) so that the post-track trigger + // fires against the cached, deducted state — see cache v2 sequencing note. + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ threshold: 0, quantity: 600 }), + }); + + // Drive base into -500 overage (usage = 1500 against allowance 1000). + // Post-track ATU trigger sees combined balance = -500 ≤ threshold 0 → fires. + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1500, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Post-ATU expected: base 0/1000, prepaid 100/100, combined remaining 100. + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 100, + }); + + // Invoice: 600 credits / 100 billing_units = 6 packs × $10 = $60. + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 60, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // options.quantity tracks FULL top-up purchase (6 packs). + await expectCustomerProductOptions({ + ctx, + customerId, + productId: oneOffProd.id, + featureId: TestFeature.Messages, + quantity: 6, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("auto-topup rebalance-4: no overage, full remainder to prepaid (backward compat)")}`, + async () => { + const baseProd = products.base({ + id: "topup-rb4-base", + items: [items.lifetimeMessages({ includedUsage: 1000 })], + }); + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-rb4-addon", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1, ctx } = await initScenario({ + customerId: "auto-topup-rb4", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [baseProd, oneOffProd] }), + ], + actions: [ + s.attach({ productId: baseProd.id }), + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 1 }], // 1 pack = 100 credits prepaid + }), + ], + }); + + // Configure ATU first. + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ threshold: 300, quantity: 600 }), + }); + + // Use 800 → base=200, prepaid=100 → combined=300. threshold=300 so trigger fires. + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 800, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Base unchanged at 200, prepaid grows by 600 to 700. Combined = 900. + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 900, + }); + + // Invoice: 600 credits = 6 packs × $10 = $60. Plus the attach invoice for 1 pack = $10. + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 60, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // options.quantity: attached with 1 pack, ATU added 6 → 7. + await expectCustomerProductOptions({ + ctx, + customerId, + productId: oneOffProd.id, + featureId: TestFeature.Messages, + quantity: 7, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("auto-topup rebalance-5: overage exceeds top-up, no remainder to prepaid")}`, + async () => { + const baseProd = products.base({ + id: "topup-rb5-base", + items: [items.lifetimeMessages({ includedUsage: 1000 })], + }); + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-rb5-addon", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1, ctx } = await initScenario({ + customerId: "auto-topup-rb5", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [baseProd, oneOffProd] }), + ], + actions: [ + s.attach({ productId: baseProd.id }), + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 0 }], + }), + ], + }); + + await setCustomerOverageAllowed({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + enabled: true, + }); + + // Configure ATU first. + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ threshold: 0, quantity: 600 }), + }); + + // Drive base to -1000 overage (usage=2000 vs allowance=1000). + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 2000, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Base balance after paydown: -400. Prepaid unchanged at 0. + // Combined: -400, reported as remaining: 0 (API clamps negative to 0). + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 0, + }); + + // Invoice still charged full 600 (6 packs × $10 = $60). + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 60, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // options.quantity still tracks FULL purchase (6 packs) even though balance landed + // entirely in base paydown. + await expectCustomerProductOptions({ + ctx, + customerId, + productId: oneOffProd.id, + featureId: TestFeature.Messages, + quantity: 6, + }); + }, +); diff --git a/server/tests/unit/balances/auto-topup/compute-rebalanced-auto-topup.test.ts b/server/tests/unit/balances/auto-topup/compute-rebalanced-auto-topup.test.ts new file mode 100644 index 000000000..702abd971 --- /dev/null +++ b/server/tests/unit/balances/auto-topup/compute-rebalanced-auto-topup.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, test } from "bun:test"; +import { + BillWhen, + BillingInterval, + type EntityBalance, + type FullCusEntWithFullCusProduct, + type FullCustomer, + PriceType, +} from "@autumn/shared"; +import { computeRebalancedAutoTopUp } from "@/internal/balances/autoTopUp/compute/computeRebalancedAutoTopUp"; + +const createCustomerEntitlement = ({ + id, + balance, + quantity = 0, + usageAllowed = false, + entities, + createdAt = 1, + allowance = 0, + entityFeatureId = null, +}: { + id: string; + balance: number; + quantity?: number; + usageAllowed?: boolean; + entities?: Record; + createdAt?: number; + allowance?: number; + entityFeatureId?: string | null; +}): FullCusEntWithFullCusProduct => { + const entitlementId = `ent-${id}`; + const customerProductId = `cus-prod-${id}`; + + return { + id: `cus-ent-${id}`, + internal_customer_id: "internal-customer", + internal_entity_id: null, + internal_feature_id: "internal-feature-messages", + customer_id: "customer-1", + feature_id: "messages", + entitlement_id: entitlementId, + customer_product_id: customerProductId, + created_at: createdAt, + unlimited: false, + balance, + additional_balance: 0, + adjustment: 0, + entities: entities ?? null, + usage_allowed: usageAllowed, + next_reset_at: null, + expires_at: null, + cache_version: 0, + external_id: null, + replaceables: [], + rollovers: [], + entitlement: { + id: entitlementId, + internal_feature_id: "internal-feature-messages", + internal_product_id: "internal-product-1", + is_custom: false, + allowance_type: "fixed", + allowance, + interval: BillingInterval.Month, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: entityFeatureId, + org_id: "org-1", + feature_id: "messages", + usage_limit: null, + rollover: null, + feature: { + id: "messages", + internal_id: "internal-feature-messages", + name: "Messages", + type: "metered", + config: {}, + org_id: "org-1", + env: "sandbox", + created_at: 1, + deleted_at: null, + }, + }, + customer_product: { + id: customerProductId, + internal_id: customerProductId, + internal_customer_id: "internal-customer", + internal_product_id: "internal-product-1", + internal_entity_id: null, + customer_id: "customer-1", + product_id: `product-${id}`, + name: `Product ${id}`, + group: "", + created_at: 1, + ended_at: null, + canceled_at: null, + cancel_at: null, + expires_at: null, + trial_ends_at: null, + trial_started_at: null, + anchor_at: null, + quantity: 1, + status: "active", + canceled: false, + version: 1, + entity_id: null, + replaces_customer_product_id: null, + options: [ + { + feature_id: "messages", + internal_feature_id: "internal-feature-messages", + quantity, + }, + ], + product: { + internal_id: "internal-product-1", + id: `product-${id}`, + name: `Product ${id}`, + description: null, + org_id: "org-1", + created_at: 1, + env: "sandbox", + is_add_on: false, + is_default: false, + group: "", + version: 1, + processor: {}, + base_variant_id: null, + archived: false, + free_trials: [], + free_trial: null, + prices: [], + entitlements: [], + }, + customer_entitlements: [], + customer_prices: [ + { + id: `cus-price-${id}`, + price_id: `price-${id}`, + customer_product_id: customerProductId, + created_at: 1, + price: { + id: `price-${id}`, + org_id: "org-1", + internal_product_id: "internal-product-1", + config: { + type: PriceType.Usage, + bill_when: BillWhen.InAdvance, + billing_units: 100, + internal_feature_id: "internal-feature-messages", + feature_id: "messages", + usage_tiers: [{ to: "inf", amount: 10 }], + interval: BillingInterval.Month, + interval_count: 1, + stripe_meter_id: null, + stripe_price_id: null, + stripe_empty_price_id: null, + stripe_product_id: null, + stripe_placeholder_price_id: null, + stripe_event_name: null, + stripe_prepaid_price_v2_id: null, + should_prorate: false, + }, + created_at: 1, + billing_type: null, + tier_behavior: null, + is_custom: false, + entitlement_id: entitlementId, + proration_config: {}, + }, + }, + ], + }, + } as unknown as FullCusEntWithFullCusProduct; +}; + +const buildFullCustomer = ( + cusEnts: FullCusEntWithFullCusProduct[], +): FullCustomer => { + const productsById = new Map(); + for (const cusEnt of cusEnts) { + const cusProduct = cusEnt.customer_product; + if (!cusProduct) continue; + const existing = productsById.get(cusProduct.id) ?? []; + existing.push(cusEnt); + productsById.set(cusProduct.id, existing); + } + + const customer_products = Array.from(productsById.entries()).map( + ([, ents]) => { + const sampleProduct = ents[0]?.customer_product!; + return { + ...sampleProduct, + customer_entitlements: ents.map((e) => ({ ...e })), + }; + }, + ); + + return { + id: "customer-1", + internal_id: "internal-customer", + org_id: "org-1", + env: "sandbox", + customer_products, + auto_topups: [], + extra_customer_entitlements: [], + invoices: [], + entities: [], + } as unknown as FullCustomer; +}; + +describe("computeRebalancedAutoTopUp", () => { + test("1. no overage: single delta to prepaid for full quantity", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const base = createCustomerEntitlement({ + id: "base", + balance: 200, + usageAllowed: true, + }); + const fullCustomer = buildFullCustomer([base, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 600, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([ + { cusEntId: prepaid.id, featureId: "messages", delta: 600 }, + ]); + }); + + test("2. single overage cusEnt: paydown delta then prepaid remainder delta", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const base = createCustomerEntitlement({ + id: "base", + balance: -500, + usageAllowed: true, + }); + const fullCustomer = buildFullCustomer([base, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 600, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([ + { cusEntId: base.id, featureId: "messages", delta: 500 }, + { cusEntId: prepaid.id, featureId: "messages", delta: 100 }, + ]); + }); + + test("3. overage exceeds quantity: only paydown delta, no prepaid delta", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const base = createCustomerEntitlement({ + id: "base", + balance: -1000, + usageAllowed: true, + }); + const fullCustomer = buildFullCustomer([base, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 600, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([ + { cusEntId: base.id, featureId: "messages", delta: 600 }, + ]); + }); + + test("4. prepaid cusEnt missing: empty deltas", () => { + const base = createCustomerEntitlement({ + id: "base", + balance: -200, + usageAllowed: true, + }); + const fullCustomer = buildFullCustomer([base]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 600, + prepaidCustomerEntitlementId: "cus-ent-missing", + }); + + expect(deltas).toEqual([]); + }); + + test("5. quantity <= 0: empty deltas", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const base = createCustomerEntitlement({ + id: "base", + balance: -500, + usageAllowed: true, + }); + const fullCustomer = buildFullCustomer([base, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 0, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([]); + }); + + test("6. entity-scoped cusEnt excluded from paydown; full quantity to prepaid", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const entityScoped = createCustomerEntitlement({ + id: "entity-scoped", + balance: 0, + usageAllowed: true, + entityFeatureId: "some-entity-feature", + entities: { + "entity-a": { + id: "entity-a", + balance: -100, + adjustment: 0, + additional_balance: 0, + }, + "entity-b": { + id: "entity-b", + balance: -100, + adjustment: 0, + additional_balance: 0, + }, + }, + }); + const fullCustomer = buildFullCustomer([entityScoped, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 300, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([ + { cusEntId: prepaid.id, featureId: "messages", delta: 300 }, + ]); + }); + + test("7. usage_allowed sorts before non-usage_allowed", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const nonUsageAllowed = createCustomerEntitlement({ + id: "non-usage-allowed", + balance: -100, + usageAllowed: false, + createdAt: 1, + }); + const usageAllowedCe = createCustomerEntitlement({ + id: "usage-allowed", + balance: -100, + usageAllowed: true, + createdAt: 2, + }); + const fullCustomer = buildFullCustomer([ + nonUsageAllowed, + usageAllowedCe, + prepaid, + ]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 150, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([ + { cusEntId: usageAllowedCe.id, featureId: "messages", delta: 100 }, + { cusEntId: nonUsageAllowed.id, featureId: "messages", delta: 50 }, + ]); + }); + + test("8. same usage_allowed value: oldest created_at paid down first", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const olderCe = createCustomerEntitlement({ + id: "older", + balance: -100, + usageAllowed: true, + createdAt: 1, + }); + const newerCe = createCustomerEntitlement({ + id: "newer", + balance: -100, + usageAllowed: true, + createdAt: 100, + }); + const fullCustomer = buildFullCustomer([newerCe, olderCe, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 100, + prepaidCustomerEntitlementId: prepaid.id, + }); + + // Only enough to zero one cusEnt. Older (createdAt=1) paid down first. + expect(deltas).toEqual([ + { cusEntId: olderCe.id, featureId: "messages", delta: 100 }, + ]); + }); + + test("9. multi-cusEnt paydown with prepaid remainder", () => { + const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 }); + const cusEntA = createCustomerEntitlement({ + id: "cus-a", + balance: -300, + usageAllowed: true, + createdAt: 1, + }); + const cusEntB = createCustomerEntitlement({ + id: "cus-b", + balance: -200, + usageAllowed: true, + createdAt: 2, + }); + const fullCustomer = buildFullCustomer([cusEntA, cusEntB, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 1000, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([ + { cusEntId: cusEntA.id, featureId: "messages", delta: 300 }, + { cusEntId: cusEntB.id, featureId: "messages", delta: 200 }, + { cusEntId: prepaid.id, featureId: "messages", delta: 500 }, + ]); + }); + + test("10. prepaid cusEnt filtered from paydown pool even when it has overage", () => { + // If the prepaid somehow has a negative balance, the paydown pool must NOT + // include it — that's what the remainder delta is for. + const prepaid = createCustomerEntitlement({ + id: "prepaid", + balance: -50, + }); + const base = createCustomerEntitlement({ + id: "base", + balance: -100, + usageAllowed: true, + }); + const fullCustomer = buildFullCustomer([base, prepaid]); + + const { deltas } = computeRebalancedAutoTopUp({ + fullCustomer, + featureId: "messages", + quantity: 600, + prepaidCustomerEntitlementId: prepaid.id, + }); + + expect(deltas).toEqual([ + { cusEntId: base.id, featureId: "messages", delta: 100 }, + { cusEntId: prepaid.id, featureId: "messages", delta: 500 }, + ]); + }); +}); diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index 402f66d05..251664d24 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -85,6 +85,23 @@ export const AutumnBillingPlanSchema = z.object({ .array(UpdateCustomerEntitlementSchema) .optional(), + /** + * Pre-computed auto top-up rebalance deltas. The compute step sizes paydown + prepaid + * remainder from the context's FullCustomer snapshot; the executor just loops these + * and applies each via adjustBalanceDbAndCache (atomic SQL balance + delta). + */ + autoTopupRebalance: z + .object({ + deltas: z.array( + z.object({ + cusEntId: z.string(), + featureId: z.string(), + delta: z.number(), + }), + ), + }) + .optional(), + // Upsert operations (populated during webhook handling, e.g., checkout.session.completed) upsertSubscription: SubscriptionSchema.optional(), upsertInvoice: z.custom().optional(),