persist overage on reset

This commit is contained in:
John Yeo
2026-04-01 17:24:07 +01:00
parent ba260a9fbf
commit 7e98c36163
11 changed files with 620 additions and 2 deletions

View File

@@ -26,10 +26,12 @@ export const resetCustomerEntitlement = async ({
ctx,
cusEnt,
updatedCusEnts,
persistFreeOverage = false,
}: {
ctx: CronContext;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
persistFreeOverage?: boolean;
}) => {
const repoContext: RepoContext = {
db: ctx.db,
@@ -130,6 +132,7 @@ export const resetCustomerEntitlement = async ({
const resetBalanceUpdate = getResetBalancesUpdate({
cusEnt,
allowance: resetBalance || undefined,
persistFreeOverage,
});
// Only check sub anchor for product-based ents (loose ents have no subscription)

View File

@@ -1,11 +1,14 @@
import {
type CustomerEntitlement,
notNullish,
type OrgConfig,
OrgConfigSchema,
type ResetCusEnt,
} from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { format } from "date-fns";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { OrgService } from "@/internal/orgs/OrgService.js";
import type { CronContext } from "../utils/CronContext";
import { clearCusEntsFromCache } from "./clearCusEntsFromCache";
import { resetCustomerEntitlement } from "./resetCustomerEntitlement";
@@ -17,6 +20,28 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
const timeoutMs = 60_000; // 1 minute
const startTime = Date.now();
const orgConfigCache = new Map<string, OrgConfig>();
const getOrgConfig = async ({
orgId,
}: {
orgId: string;
}): Promise<OrgConfig> => {
const cached = orgConfigCache.get(orgId);
if (cached) return cached;
try {
const org = await OrgService.get({ db, orgId });
const config = OrgConfigSchema.parse(org.config || {});
orgConfigCache.set(orgId, config);
return config;
} catch {
const defaultConfig = OrgConfigSchema.parse({});
orgConfigCache.set(orgId, defaultConfig);
return defaultConfig;
}
};
try {
let iteration = 0;
@@ -43,14 +68,23 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
const batchSize = 1000;
for (let i = 0; i < cusEnts.length; i += batchSize) {
const batch = cusEnts.slice(i, i + batchSize);
// Pre-fetch org configs for all unique org_ids in this batch
const uniqueOrgIds = new Set(batch.map((ce) => ce.customer.org_id));
await Promise.all(
[...uniqueOrgIds].map((orgId) => getOrgConfig({ orgId })),
);
const batchResets = [];
const updatedCusEnts: ResetCusEnt[] = [];
for (const cusEnt of batch) {
const orgConfig = orgConfigCache.get(cusEnt.customer.org_id);
batchResets.push(
resetCustomerEntitlement({
ctx,
cusEnt: cusEnt,
updatedCusEnts,
persistFreeOverage: orgConfig?.persist_free_overage ?? false,
}),
);
}

View File

@@ -4,6 +4,7 @@ import {
type FeatureOptions,
type FullCusProduct,
type FullCustomerPrice,
orgPersistFreeOverage,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -64,6 +65,7 @@ export const handlePrepaidPrices = async ({
const resetUpdate = getResetBalancesUpdate({
cusEnt,
allowance: newAllowance,
persistFreeOverage: orgPersistFreeOverage({ org }),
});
// console.log("--------------------------------");

View File

@@ -7,6 +7,7 @@ import {
getStartingBalance,
isLifetimeEntitlement,
isUnlimitedEntitlement,
orgPersistFreeOverage,
type Rollover,
} from "@autumn/shared";
import { logger } from "better-auth";
@@ -85,9 +86,11 @@ export const processReset = async ({
});
// Compute reset balance update
const persistFreeOverage = orgPersistFreeOverage({ org: ctx.org });
const resetBalanceUpdate = getResetBalancesUpdate({
cusEnt,
allowance: resetBalance,
persistFreeOverage,
});
const updates: ResetUpdates =

View File

@@ -1,16 +1,24 @@
import type { EntityBalance, FullCustomerEntitlement } from "@autumn/shared";
import { Decimal } from "decimal.js";
import { notNullish } from "@/utils/genUtils.js";
export type ResetBalancesUpdate =
| { entities: Record<string, EntityBalance> }
| { balance: number; additional_balance: number; adjustment: number };
/** Returns the overage amount to deduct: max(0, -balance). */
const computeOverageDeduction = ({ balance }: { balance: number }): Decimal => {
return Decimal.max(0, new Decimal(balance).neg());
};
export const getResetBalancesUpdate = ({
cusEnt,
allowance,
persistFreeOverage = false,
}: {
cusEnt: FullCustomerEntitlement;
allowance?: number;
persistFreeOverage?: boolean;
}): ResetBalancesUpdate => {
const newBalance = notNullish(allowance)
? allowance!
@@ -21,14 +29,32 @@ export const getResetBalancesUpdate = ({
if (notNullish(entitlement.entity_feature_id)) {
const newEntities = { ...cusEnt.entities };
for (const entityId in newEntities) {
newEntities[entityId].balance = newBalance;
const entity = newEntities[entityId];
let entityResetBalance = newBalance;
if (persistFreeOverage) {
const overage = computeOverageDeduction({
balance: entity.balance,
});
entityResetBalance = new Decimal(newBalance).sub(overage).toNumber();
}
newEntities[entityId].balance = entityResetBalance;
newEntities[entityId].adjustment = 0;
}
return { entities: newEntities };
}
let resetBalance = newBalance;
if (persistFreeOverage) {
const overage = computeOverageDeduction({
balance: cusEnt.balance ?? 0,
});
resetBalance = new Decimal(newBalance).sub(overage).toNumber();
}
return {
balance: newBalance,
balance: resetBalance,
additional_balance: 0,
adjustment: 0,
};

View File

@@ -9,5 +9,7 @@ export const temp: TestGroup = {
"integration/balances/track/overage-allowed/",
"integration/crud/customers/customer-billing-controls.test.ts",
"integration/crud/entities/update-entity-billing-controls.test.ts",
"integration/balances/reset/persist-free-overage-on.test.ts",
"integration/balances/reset/persist-free-overage-off.test.ts",
],
};

View File

@@ -12,12 +12,14 @@ export const resetAndGetCusEnt = async ({
productGroup,
featureId,
skipCacheDeletion = false,
persistFreeOverage = false,
}: {
ctx: TestContext;
customer: Customer;
productGroup: string;
featureId: string;
skipCacheDeletion?: boolean;
persistFreeOverage?: boolean;
}) => {
const { db } = ctx;
// Run reset cusEnt on ...
@@ -41,6 +43,7 @@ export const resetAndGetCusEnt = async ({
ctx,
cusEnt: resetCusEnt,
updatedCusEnts: [],
persistFreeOverage,
});
if (!skipCacheDeletion) {

View File

@@ -0,0 +1,84 @@
import { expect, test } from "bun:test";
import {
type ApiCustomer,
customerEntitlements,
type OrgConfig,
} from "@autumn/shared";
import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { db } from "@/db/initDrizzle.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
const setBalanceInDb = async ({
cusEntId,
balance,
}: {
cusEntId: string;
balance: number;
}) => {
await db
.update(customerEntitlements)
.set({ balance })
.where(eq(customerEntitlements.id, cusEntId));
};
const disablePersistFreeOverage = async ({
orgId,
orgConfig,
}: {
orgId: string;
orgConfig: OrgConfig;
}) => {
await OrgService.update({
db,
orgId,
updates: { config: { ...orgConfig, persist_free_overage: false } },
});
};
// ─────────────────────────────────────────────────────────────────
// 1. Flag off — negative balance resets to full allowance
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage OFF: resets to full allowance when disabled")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "persist-ovg-off-basic",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
actions: [s.attach({ productId: free.id })],
});
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: -100 });
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const after = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(after.balances[TestFeature.Messages].current_balance).toBe(100);
expect(after.balances[TestFeature.Messages].usage).toBe(0);
});

View File

@@ -0,0 +1,456 @@
import { expect, test } from "bun:test";
import {
type ApiCustomer,
type ApiCustomerV3,
customerEntitlements,
type OrgConfig,
} from "@autumn/shared";
import { resetAndGetCusEnt } from "@tests/balances/track/rollovers/rolloverTestUtils.js";
import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import {
expireCusEntForReset,
setCachedCusEntField,
} from "@tests/utils/cusProductUtils/resetTestUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { db } from "@/db/initDrizzle.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
const setBalanceInDb = async ({
cusEntId,
balance,
}: {
cusEntId: string;
balance: number;
}) => {
await db
.update(customerEntitlements)
.set({ balance })
.where(eq(customerEntitlements.id, cusEntId));
};
const enablePersistFreeOverage = async ({
orgId,
orgConfig,
}: {
orgId: string;
orgConfig: OrgConfig;
}) => {
await OrgService.update({
db,
orgId,
updates: { config: { ...orgConfig, persist_free_overage: true } },
});
};
const disablePersistFreeOverage = async ({
orgId,
orgConfig,
}: {
orgId: string;
orgConfig: OrgConfig;
}) => {
await OrgService.update({
db,
orgId,
updates: { config: { ...orgConfig, persist_free_overage: false } },
});
};
// ─────────────────────────────────────────────────────────────────
// 1. Lazy reset (DB path)
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage ON (DB): lazy reset deducts overage")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "persist-ovg-on-db",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: -100 });
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const after = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(after.balances[TestFeature.Messages].current_balance).toBe(0);
expect(after.balances[TestFeature.Messages].usage).toBe(100);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
});
// ─────────────────────────────────────────────────────────────────
// 2. Lazy reset (cache path)
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage ON (cache): lazy reset deducts overage from cache")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "persist-ovg-on-cache",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
try {
await autumnV2.customers.get<ApiCustomer>(customerId);
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: -50 });
await setCachedCusEntField({
orgId: ctx.org.id,
env: ctx.env,
customerId,
cusEntId: cusEnt!.id,
field: "balance",
value: -50,
});
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const after = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(after.balances[TestFeature.Messages].current_balance).toBe(50);
expect(after.balances[TestFeature.Messages].usage).toBe(50);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
});
// ─────────────────────────────────────────────────────────────────
// 3. Cron reset path
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage ON (cron): cron reset deducts overage")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, ctx, customer } = await initScenario({
customerId: "persist-ovg-on-cron",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
actions: [s.attach({ productId: free.id })],
});
const cusEntBefore = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntBefore).toBeDefined();
await setBalanceInDb({ cusEntId: cusEntBefore!.id, balance: -75 });
const cusEntAfter = await resetAndGetCusEnt({
ctx,
customer: customer!,
productGroup: customerId,
featureId: TestFeature.Messages,
persistFreeOverage: true,
});
expect(cusEntAfter!.balance).toBe(25);
});
// ─────────────────────────────────────────────────────────────────
// 4. No overage — positive balance resets normally
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage ON (no overage): positive balance resets to full allowance")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "persist-ovg-on-positive",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: 50 });
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const after = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(after.balances[TestFeature.Messages].current_balance).toBe(100);
expect(after.balances[TestFeature.Messages].usage).toBe(0);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
});
// ─────────────────────────────────────────────────────────────────
// 5. Per-entity — independent overage per entity
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage ON (per-entity): each entity carries its own overage")}`, async () => {
const perEntityMessages = items.monthlyMessages({
includedUsage: 100,
entityFeatureId: TestFeature.Users,
});
const free = products.base({ items: [perEntityMessages] });
const { customerId, ctx } = await initScenario({
customerId: "persist-ovg-on-entity",
setup: [
s.customer({ testClock: false }),
s.products({ list: [free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
ctx.org.config = { ...ctx.org.config, persist_free_overage: true };
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
expect(cusEnt!.entities).toBeDefined();
const entities = { ...cusEnt!.entities! };
const entityIds = Object.keys(entities);
expect(entityIds.length).toBe(2);
entities[entityIds[0]].balance = -50;
entities[entityIds[0]].adjustment = 0;
entities[entityIds[1]].balance = 30;
entities[entityIds[1]].adjustment = 0;
await db
.update(customerEntitlements)
.set({ entities })
.where(eq(customerEntitlements.id, cusEnt!.id));
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.entities).toBeDefined();
expect(cusEntAfter!.entities![entityIds[0]].balance).toBe(50);
expect(cusEntAfter!.entities![entityIds[1]].balance).toBe(100);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
});
// ─────────────────────────────────────────────────────────────────
// 6. Per-entity mixed — different overage amounts
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage ON (per-entity mixed): different overage per entity")}`, async () => {
const perEntityMessages = items.monthlyMessages({
includedUsage: 100,
entityFeatureId: TestFeature.Users,
});
const free = products.base({ items: [perEntityMessages] });
const { customerId, ctx } = await initScenario({
customerId: "persist-ovg-on-ent-mix",
setup: [
s.customer({ testClock: false }),
s.products({ list: [free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
ctx.org.config = { ...ctx.org.config, persist_free_overage: true };
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
expect(cusEnt!.entities).toBeDefined();
const entities = { ...cusEnt!.entities! };
const entityIds = Object.keys(entities);
expect(entityIds.length).toBe(2);
entities[entityIds[0]].balance = -30;
entities[entityIds[0]].adjustment = 0;
entities[entityIds[1]].balance = -200;
entities[entityIds[1]].adjustment = 0;
await db
.update(customerEntitlements)
.set({ entities })
.where(eq(customerEntitlements.id, cusEnt!.id));
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.entities).toBeDefined();
expect(cusEntAfter!.entities![entityIds[0]].balance).toBe(70);
expect(cusEntAfter!.entities![entityIds[1]].balance).toBe(-100);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
});
// ─────────────────────────────────────────────────────────────────
// 7. Prepaid monthly + lifetime — overage carries on paid reset
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("persist overage ON (prepaid): invoice reset carries prepaid overage")}`, async () => {
const prepaidItem = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const lifetimeItem = items.lifetimeMessages({ includedUsage: 50 });
const pro = products.pro({ items: [prepaidItem, lifetimeItem] });
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "persist-ovg-on-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
s.track({ featureId: TestFeature.Messages, value: 300, timeout: 2000 }),
],
});
// Enable flag BEFORE advancing, so the webhook handler sees it in DB
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
try {
// After attach: prepaid balance = 200 (quantity=200, billingUnits=100, so 2*100=200), lifetime = 50
// Total messages balance = 200 + 50 = 250
// After track 300: deducted 300 from messages
// The prepaid cusEnt's balance should be negative (overage)
// On advance, handlePrepaidPrices resets prepaid with persistFreeOverage
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Lifetime messages (50) should be untouched by the prepaid reset
// Prepaid: was 200, used 300 -> overage of some amount on the prepaid cusEnt
// After reset with persist_free_overage: new prepaid balance = 200 - overage
// The exact split depends on deduction order, so just verify the feature exists
// and the balance is less than the full 250 (200 prepaid + 50 lifetime)
expect(customer.features[TestFeature.Messages]).toBeDefined();
expect(customer.features[TestFeature.Messages].balance).toBeLessThan(250);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
});

View File

@@ -32,6 +32,7 @@ export const OrgConfigSchema = z.object({
disable_stripe_writes: z.boolean().default(false),
disabled_auto_topup: z.boolean().default(false),
persist_free_overage: z.boolean().default(false),
dryrun_autotopups: z.boolean().default(false),
});

View File

@@ -41,3 +41,7 @@ export const orgDisableStripeWrites = ({ ctx }: { ctx: SharedContext }) => {
}
return ctx.org.config.disable_stripe_writes;
};
export const orgPersistFreeOverage = ({ org }: { org: Organization }) => {
return org.config.persist_free_overage ?? false;
};