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({
lineItems: [lineItem],
});

View File

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

View File

@@ -27,10 +27,6 @@ const getAutoTopupFullCustomer = async ({
ctx: AutumnContext;
customerId: string;
}): Promise<FullCustomer | undefined> => {
// 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,

View File

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

View File

@@ -67,7 +67,10 @@ export const logAutumnBillingPlan = ({
autoTopupRebalance: plan.autoTopupRebalance
? plan.autoTopupRebalance.deltas
.map(({ cusEntId, delta }) => `${cusEntId}: ${delta > 0 ? "+" : ""}${delta}`)
.map(
({ cusEntId, delta }) =>
`${cusEntId}: ${delta > 0 ? "+" : ""}${delta}`,
)
.join(", ") || "no-op"
: "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
* the one-off prepaid cusEnt.
*
* Architecture note: paydown computation runs at EXECUTE time (post-Stripe-charge),
* not compute time. The billing plan carries only a declarative intent
* (`autoTopupRebalance: { featureId, quantity, prepaidCustomerEntitlementId }`);
* `applyAutoTopupRebalance` reads LIVE cusEnt balances (cache → DB fallback) and
* applies race-safe atomic delta writes via `adjustBalanceDbAndCache`. This avoids
* both the stale-snapshot overwrite race (data loss) and the snapshot-deeper-than-
* live overcredit race.
* 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
@@ -81,30 +84,20 @@ test.concurrent(
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,
});
// 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);
// 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.
// ATU threshold=300, quantity=600 → no overage, full remainder to 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 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);
// 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);
expectBalanceCorrect({
customer: after,
@@ -258,6 +241,11 @@ test.concurrent(
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,
@@ -265,20 +253,6 @@ test.concurrent(
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);
// 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({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 90,
});
// Configure ATU first. customers.update invalidates the FullSubject cache;
// doing it before the deduction avoids rehydrating from a pre-deduction DB
// state under cache v2.
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
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({
customer_id: customerId,
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)
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 85,
});
// Now set auto top-up config
// Configure ATU first. customers.update invalidates the FullSubject cache;
// doing it before the deduction avoids rehydrating from a pre-deduction DB
// state under cache v2.
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
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
// Both the check path and the deduction path call triggerAutoTopUp
// Burst suppression should ensure only ONE top-up fires → balance = 13 + 100 = 113

View File

@@ -225,7 +225,9 @@ describe("computeRebalancedAutoTopUp", () => {
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", () => {
@@ -245,8 +247,8 @@ describe("computeRebalancedAutoTopUp", () => {
});
expect(deltas).toEqual([
{ cusEntId: base.id, delta: 500 },
{ cusEntId: prepaid.id, delta: 100 },
{ cusEntId: base.id, featureId: "messages", delta: 500 },
{ cusEntId: prepaid.id, featureId: "messages", delta: 100 },
]);
});
@@ -266,7 +268,9 @@ describe("computeRebalancedAutoTopUp", () => {
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", () => {
@@ -337,7 +341,9 @@ describe("computeRebalancedAutoTopUp", () => {
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", () => {
@@ -368,8 +374,8 @@ describe("computeRebalancedAutoTopUp", () => {
});
expect(deltas).toEqual([
{ cusEntId: usageAllowedCe.id, delta: 100 },
{ cusEntId: nonUsageAllowed.id, delta: 50 },
{ cusEntId: usageAllowedCe.id, featureId: "messages", delta: 100 },
{ 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.
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", () => {
@@ -424,9 +432,9 @@ describe("computeRebalancedAutoTopUp", () => {
});
expect(deltas).toEqual([
{ cusEntId: cusEntA.id, delta: 300 },
{ cusEntId: cusEntB.id, delta: 200 },
{ cusEntId: prepaid.id, delta: 500 },
{ cusEntId: cusEntA.id, featureId: "messages", delta: 300 },
{ cusEntId: cusEntB.id, featureId: "messages", delta: 200 },
{ cusEntId: prepaid.id, featureId: "messages", delta: 500 },
]);
});
@@ -452,8 +460,8 @@ describe("computeRebalancedAutoTopUp", () => {
});
expect(deltas).toEqual([
{ cusEntId: base.id, delta: 100 },
{ cusEntId: prepaid.id, delta: 500 },
{ cusEntId: base.id, featureId: "messages", delta: 100 },
{ cusEntId: prepaid.id, featureId: "messages", delta: 500 },
]);
});
});

View File

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