Merge branch 'feat/recalculate-auto-top-ups' into dev

This commit is contained in:
amianthus
2026-04-27 17:01:00 +01:00
9 changed files with 81 additions and 97 deletions

View File

@@ -99,7 +99,7 @@ export const computeAutoTopupPlan = ({
}, },
}; };
// E. Build stripe invoice action (manual — bypassing evaluateStripeBillingPlan) // D. Build stripe invoice action (manual — bypassing evaluateStripeBillingPlan)
const addLineParams = lineItemsToInvoiceAddLinesParams({ const addLineParams = lineItemsToInvoiceAddLinesParams({
lineItems: [lineItem], lineItems: [lineItem],
}); });

View File

@@ -12,6 +12,7 @@ import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLo
export type AutoTopupRebalanceDelta = { export type AutoTopupRebalanceDelta = {
cusEntId: string; cusEntId: string;
featureId: string;
delta: number; delta: number;
}; };
@@ -108,12 +109,12 @@ export const computeRebalancedAutoTopUp = ({
for (const [cusEntId, update] of Object.entries(updates)) { for (const [cusEntId, update] of Object.entries(updates)) {
const delta = -update.deducted; const delta = -update.deducted;
if (delta === 0) continue; if (delta === 0) continue;
deltas.push({ cusEntId, delta }); deltas.push({ cusEntId, featureId, delta });
} }
} }
if (remainder > 0) { if (remainder > 0) {
deltas.push({ cusEntId: prepaidCusEnt.id, delta: remainder }); deltas.push({ cusEntId: prepaidCusEnt.id, featureId, delta: remainder });
} }
return { deltas }; return { deltas };

View File

@@ -27,10 +27,6 @@ const getAutoTopupFullCustomer = async ({
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
}): Promise<FullCustomer | undefined> => { }): Promise<FullCustomer | undefined> => {
// console.log(`GETTING AUTO TOP UP CUSTOMER ${customerId}`);
// console.log(
// `IS FULL SUBJECT ROLLOUT ENABLED: ${isFullSubjectRolloutEnabled({ ctx })}`,
// );
if (isFullSubjectRolloutEnabled({ ctx })) { if (isFullSubjectRolloutEnabled({ ctx })) {
const { fullSubject: cachedFullSubject } = await getCachedFullSubject({ const { fullSubject: cachedFullSubject } = await getCachedFullSubject({
ctx, ctx,
@@ -68,7 +64,6 @@ const getAutoTopupFullCustomer = async ({
let fullCustomer = await getCachedFullCustomer({ ctx, customerId }); let fullCustomer = await getCachedFullCustomer({ ctx, customerId });
if (!fullCustomer) { if (!fullCustomer) {
console.log(`NO CACHED FULL CUSTOMER, FETCHING FROM DB`);
fullCustomer = await CusService.getFull({ fullCustomer = await CusService.getFull({
ctx, ctx,
idOrInternalId: customerId, idOrInternalId: customerId,

View File

@@ -1,11 +1,7 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; 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"; import { customerEntitlementActions } from "@/internal/customers/cusProducts/cusEnts/actions/index.js";
export type AutoTopupRebalanceDelta = {
cusEntId: string;
delta: number;
};
/** /**
* Apply pre-computed auto top-up rebalance deltas. Each delta is an atomic SQL * Apply pre-computed auto top-up rebalance deltas. Each delta is an atomic SQL
* `balance + delta` increment (+ Redis JSON.NUMINCRBY), so concurrent deductions * `balance + delta` increment (+ Redis JSON.NUMINCRBY), so concurrent deductions
@@ -23,13 +19,14 @@ export const executeAutoTopupRebalance = async ({
customerId: string; customerId: string;
deltas: AutoTopupRebalanceDelta[]; deltas: AutoTopupRebalanceDelta[];
}): Promise<void> => { }): Promise<void> => {
for (const { cusEntId, delta } of deltas) { for (const { cusEntId, featureId, delta } of deltas) {
if (delta === 0) continue; if (delta === 0) continue;
await customerEntitlementActions.adjustBalanceDbAndCache({ await customerEntitlementActions.adjustBalanceDbAndCache({
ctx, ctx,
customerId, customerId,
cusEntId, cusEntId,
featureId,
delta, delta,
}); });
} }

View File

@@ -67,7 +67,10 @@ export const logAutumnBillingPlan = ({
autoTopupRebalance: plan.autoTopupRebalance autoTopupRebalance: plan.autoTopupRebalance
? plan.autoTopupRebalance.deltas ? plan.autoTopupRebalance.deltas
.map(({ cusEntId, delta }) => `${cusEntId}: ${delta > 0 ? "+" : ""}${delta}`) .map(
({ cusEntId, delta }) =>
`${cusEntId}: ${delta > 0 ? "+" : ""}${delta}`,
)
.join(", ") || "no-op" .join(", ") || "no-op"
: "none", : "none",
}, },

View File

@@ -20,13 +20,16 @@ const AUTO_TOPUP_WAIT_MS = 40000;
* on non-prepaid, non-entity-scoped top-level cusEnts before routing the remainder to * on non-prepaid, non-entity-scoped top-level cusEnts before routing the remainder to
* the one-off prepaid cusEnt. * the one-off prepaid cusEnt.
* *
* Architecture note: paydown computation runs at EXECUTE time (post-Stripe-charge), * Architecture note: paydown computation runs at compute time from the billing
* not compute time. The billing plan carries only a declarative intent * context's FullCustomer snapshot. The billing plan carries pre-computed deltas
* (`autoTopupRebalance: { featureId, quantity, prepaidCustomerEntitlementId }`); * (`autoTopupRebalance: { deltas: [{ cusEntId, featureId, delta }] }`), and execute
* `applyAutoTopupRebalance` reads LIVE cusEnt balances (cache → DB fallback) and * applies them with race-safe atomic delta writes via `adjustBalanceDbAndCache`.
* applies race-safe atomic delta writes via `adjustBalanceDbAndCache`. This avoids *
* both the stale-snapshot overwrite race (data loss) and the snapshot-deeper-than- * Cache v2 sequencing note: `customers.update(billing_controls)` invalidates the
* live overcredit race. * 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 * Entity-scoped cusEnts are intentionally excluded from paydown — there is no
* race-safe per-entity atomic increment primitive today. Entity-scoped overage is * race-safe per-entity atomic increment primitive today. Entity-scoped overage is
@@ -81,30 +84,20 @@ test.concurrent(
enabled: true, 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). // 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({ await autumnV2_1.track({
customer_id: customerId, customer_id: customerId,
feature_id: TestFeature.Messages, feature_id: TestFeature.Messages,
value: 1500, value: 1500,
}); });
// Configure ATU: threshold=0 (combined balance currently -500 ≤ 0), quantity=600.
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 0,
quantity: 600,
}),
});
// Fire a zero-value track to push the auto-topup trigger path (trigger happens
// after any deduction). Value 0 keeps balance the same but still invokes the
// post-track trigger check.
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 0,
});
await timeout(AUTO_TOPUP_WAIT_MS); await timeout(AUTO_TOPUP_WAIT_MS);
// Post-ATU expected: base 0/1000, prepaid 100/100, combined remaining 100. // Post-ATU expected: base 0/1000, prepaid 100/100, combined remaining 100.
@@ -167,31 +160,21 @@ test.concurrent(
], ],
}); });
// Use 750 of base + 50 of prepaid = 800 total. Base=250, prepaid=50. Combined=300. // Configure ATU first.
// ATU threshold=300, quantity=600 → no overage, full remainder to prepaid. 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({ await autumnV2_1.track({
customer_id: customerId, customer_id: customerId,
feature_id: TestFeature.Messages, feature_id: TestFeature.Messages,
value: 800, value: 800,
}); });
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 300,
quantity: 600,
}),
});
// Track 0 to trigger post-track ATU check now that config is set.
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 0,
});
await timeout(AUTO_TOPUP_WAIT_MS); await timeout(AUTO_TOPUP_WAIT_MS);
// Base unchanged at 250, prepaid grows by 600 to 650. Combined = 900. // Base unchanged at 200, prepaid grows by 600 to 700. Combined = 900.
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId); const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({ expectBalanceCorrect({
customer: after, customer: after,
@@ -258,6 +241,11 @@ test.concurrent(
enabled: true, 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). // Drive base to -1000 overage (usage=2000 vs allowance=1000).
await autumnV2_1.track({ await autumnV2_1.track({
customer_id: customerId, customer_id: customerId,
@@ -265,20 +253,6 @@ test.concurrent(
value: 2000, value: 2000,
}); });
// Top-up 600 cannot cover all 1000 overage — only pays down 600 of it.
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 0,
quantity: 600,
}),
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 0,
});
await timeout(AUTO_TOPUP_WAIT_MS); await timeout(AUTO_TOPUP_WAIT_MS);
// Base balance after paydown: -400. Prepaid unchanged at 0. // Base balance after paydown: -400. Prepaid unchanged at 0.

View File

@@ -38,12 +38,9 @@ test.concurrent(`${chalk.yellowBright("auto-topup trigger 1: when balance is 0 a
], ],
}); });
await autumnV2_1.track({ // Configure ATU first. customers.update invalidates the FullSubject cache;
customer_id: customerId, // doing it before the deduction avoids rehydrating from a pre-deduction DB
feature_id: TestFeature.Messages, // state under cache v2.
value: 90,
});
await autumnV2_1.customers.update(customerId, { await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({ billing_controls: makeAutoTopupConfig({
threshold: 20, threshold: 20,
@@ -51,6 +48,12 @@ test.concurrent(`${chalk.yellowBright("auto-topup trigger 1: when balance is 0 a
}), }),
}); });
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 90,
});
await autumnV2_1.check({ await autumnV2_1.check({
customer_id: customerId, customer_id: customerId,
feature_id: TestFeature.Messages, feature_id: TestFeature.Messages,
@@ -163,14 +166,9 @@ test.concurrent(`${chalk.yellowBright("auto-topup trigger 3: track depletes, the
], ],
}); });
// Track 85 → balance = 15 (below future threshold, but no config yet) // Configure ATU first. customers.update invalidates the FullSubject cache;
await autumnV2_1.track({ // doing it before the deduction avoids rehydrating from a pre-deduction DB
customer_id: customerId, // state under cache v2.
feature_id: TestFeature.Messages,
value: 85,
});
// Now set auto top-up config
await autumnV2_1.customers.update(customerId, { await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({ billing_controls: makeAutoTopupConfig({
threshold: 20, threshold: 20,
@@ -178,6 +176,13 @@ test.concurrent(`${chalk.yellowBright("auto-topup trigger 3: track depletes, the
}), }),
}); });
// Track 85 → balance = 15 (below threshold, deduction triggers ATU directly)
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 85,
});
// check with send_event=true deducts 2 more → balance = 13 // check with send_event=true deducts 2 more → balance = 13
// Both the check path and the deduction path call triggerAutoTopUp // Both the check path and the deduction path call triggerAutoTopUp
// Burst suppression should ensure only ONE top-up fires → balance = 13 + 100 = 113 // Burst suppression should ensure only ONE top-up fires → balance = 13 + 100 = 113

View File

@@ -225,7 +225,9 @@ describe("computeRebalancedAutoTopUp", () => {
prepaidCustomerEntitlementId: prepaid.id, prepaidCustomerEntitlementId: prepaid.id,
}); });
expect(deltas).toEqual([{ cusEntId: prepaid.id, delta: 600 }]); expect(deltas).toEqual([
{ cusEntId: prepaid.id, featureId: "messages", delta: 600 },
]);
}); });
test("2. single overage cusEnt: paydown delta then prepaid remainder delta", () => { test("2. single overage cusEnt: paydown delta then prepaid remainder delta", () => {
@@ -245,8 +247,8 @@ describe("computeRebalancedAutoTopUp", () => {
}); });
expect(deltas).toEqual([ expect(deltas).toEqual([
{ cusEntId: base.id, delta: 500 }, { cusEntId: base.id, featureId: "messages", delta: 500 },
{ cusEntId: prepaid.id, delta: 100 }, { cusEntId: prepaid.id, featureId: "messages", delta: 100 },
]); ]);
}); });
@@ -266,7 +268,9 @@ describe("computeRebalancedAutoTopUp", () => {
prepaidCustomerEntitlementId: prepaid.id, prepaidCustomerEntitlementId: prepaid.id,
}); });
expect(deltas).toEqual([{ cusEntId: base.id, delta: 600 }]); expect(deltas).toEqual([
{ cusEntId: base.id, featureId: "messages", delta: 600 },
]);
}); });
test("4. prepaid cusEnt missing: empty deltas", () => { test("4. prepaid cusEnt missing: empty deltas", () => {
@@ -337,7 +341,9 @@ describe("computeRebalancedAutoTopUp", () => {
prepaidCustomerEntitlementId: prepaid.id, prepaidCustomerEntitlementId: prepaid.id,
}); });
expect(deltas).toEqual([{ cusEntId: prepaid.id, delta: 300 }]); expect(deltas).toEqual([
{ cusEntId: prepaid.id, featureId: "messages", delta: 300 },
]);
}); });
test("7. usage_allowed sorts before non-usage_allowed", () => { test("7. usage_allowed sorts before non-usage_allowed", () => {
@@ -368,8 +374,8 @@ describe("computeRebalancedAutoTopUp", () => {
}); });
expect(deltas).toEqual([ expect(deltas).toEqual([
{ cusEntId: usageAllowedCe.id, delta: 100 }, { cusEntId: usageAllowedCe.id, featureId: "messages", delta: 100 },
{ cusEntId: nonUsageAllowed.id, delta: 50 }, { cusEntId: nonUsageAllowed.id, featureId: "messages", delta: 50 },
]); ]);
}); });
@@ -397,7 +403,9 @@ describe("computeRebalancedAutoTopUp", () => {
}); });
// Only enough to zero one cusEnt. Older (createdAt=1) paid down first. // Only enough to zero one cusEnt. Older (createdAt=1) paid down first.
expect(deltas).toEqual([{ cusEntId: olderCe.id, delta: 100 }]); expect(deltas).toEqual([
{ cusEntId: olderCe.id, featureId: "messages", delta: 100 },
]);
}); });
test("9. multi-cusEnt paydown with prepaid remainder", () => { test("9. multi-cusEnt paydown with prepaid remainder", () => {
@@ -424,9 +432,9 @@ describe("computeRebalancedAutoTopUp", () => {
}); });
expect(deltas).toEqual([ expect(deltas).toEqual([
{ cusEntId: cusEntA.id, delta: 300 }, { cusEntId: cusEntA.id, featureId: "messages", delta: 300 },
{ cusEntId: cusEntB.id, delta: 200 }, { cusEntId: cusEntB.id, featureId: "messages", delta: 200 },
{ cusEntId: prepaid.id, delta: 500 }, { cusEntId: prepaid.id, featureId: "messages", delta: 500 },
]); ]);
}); });
@@ -452,8 +460,8 @@ describe("computeRebalancedAutoTopUp", () => {
}); });
expect(deltas).toEqual([ expect(deltas).toEqual([
{ cusEntId: base.id, delta: 100 }, { cusEntId: base.id, featureId: "messages", delta: 100 },
{ cusEntId: prepaid.id, delta: 500 }, { cusEntId: prepaid.id, featureId: "messages", delta: 500 },
]); ]);
}); });
}); });

View File

@@ -95,6 +95,7 @@ export const AutumnBillingPlanSchema = z.object({
deltas: z.array( deltas: z.array(
z.object({ z.object({
cusEntId: z.string(), cusEntId: z.string(),
featureId: z.string(),
delta: z.number(), delta: z.number(),
}), }),
), ),