fix: track negative values cap at granted balance

This commit is contained in:
John Yeo
2025-11-19 14:47:23 +00:00
parent 970195c35c
commit c3e2135572
29 changed files with 584 additions and 297 deletions

View File

@@ -21,3 +21,4 @@ BUN_PARALLEL_COMPACT \
'server/tests/balances/track/allocated' \
'server/tests/balances/track/entity-balances' \
'server/tests/balances/track/concurrency' \
'server/tests/balances/track/negative' \

View File

@@ -197,68 +197,82 @@ local function deductFromCurrentBalance(cusFeature, amount, adjustGrantedBalance
local breakdownCurrentBalance = breakdown.current_balance or 0
-- For refunds (negative amount), always apply. For deductions, only if balance > 0
if remaining < 0 or breakdownCurrentBalance > 0 then
-- Calculate how much we can deduct (ensure current_balance never goes below 0)
local maxDeductible = breakdownCurrentBalance
local toDeduct = math.min(remaining, maxDeductible)
-- Collect Redis deltas
table.insert(deltas, {key = breakdown._key, field = "current_balance", delta = -toDeduct})
table.insert(deltas, {key = cusFeature._key, field = "current_balance", delta = -toDeduct})
-- Either increment usage or decrement granted_balance based on flag
if adjustGrantedBalance then
table.insert(deltas, {key = breakdown._key, field = "granted_balance", delta = -toDeduct})
table.insert(deltas, {key = cusFeature._key, field = "granted_balance", delta = -toDeduct})
local toDeduct
if remaining < 0 then
-- Refund/Negative track: Cap at granted_balance
-- We want to add (-remaining) to current_balance
-- But we can add at most (granted_balance - current_balance)
local grantedBalance = breakdown.granted_balance or 0
local maxAddable = math.max(0, grantedBalance - breakdownCurrentBalance)
local toAdd = math.min(-remaining, maxAddable)
toDeduct = -toAdd
else
table.insert(deltas, {key = breakdown._key, field = "usage", delta = toDeduct})
table.insert(deltas, {key = cusFeature._key, field = "usage", delta = toDeduct})
-- Deduction: Cap at current_balance
-- Calculate how much we can deduct (ensure current_balance never goes below 0)
local maxDeductible = breakdownCurrentBalance
toDeduct = math.min(remaining, maxDeductible)
end
-- Collect state changes
local newBalance = breakdownCurrentBalance - toDeduct
-- Ensure current_balance never goes below 0
if newBalance < 0 then
newBalance = 0
end
table.insert(stateChanges, {
type = "breakdown",
index = index,
field = "current_balance",
newValue = newBalance
})
if adjustGrantedBalance then
if toDeduct ~= 0 then
-- Collect Redis deltas
table.insert(deltas, {key = breakdown._key, field = "current_balance", delta = -toDeduct})
table.insert(deltas, {key = cusFeature._key, field = "current_balance", delta = -toDeduct})
-- Either increment usage or decrement granted_balance based on flag
if adjustGrantedBalance then
table.insert(deltas, {key = breakdown._key, field = "granted_balance", delta = -toDeduct})
table.insert(deltas, {key = cusFeature._key, field = "granted_balance", delta = -toDeduct})
else
table.insert(deltas, {key = breakdown._key, field = "usage", delta = toDeduct})
table.insert(deltas, {key = cusFeature._key, field = "usage", delta = toDeduct})
end
-- Collect state changes
local newBalance = breakdownCurrentBalance - toDeduct
-- Ensure current_balance never goes below 0
if newBalance < 0 then
newBalance = 0
end
table.insert(stateChanges, {
type = "breakdown",
index = index,
field = "granted_balance",
delta = -toDeduct
field = "current_balance",
newValue = newBalance
})
if adjustGrantedBalance then
table.insert(stateChanges, {
type = "breakdown",
index = index,
field = "granted_balance",
delta = -toDeduct
})
table.insert(stateChanges, {
type = "cusFeature",
field = "granted_balance",
delta = -toDeduct
})
else
table.insert(stateChanges, {
type = "breakdown",
index = index,
field = "usage",
delta = toDeduct
})
table.insert(stateChanges, {
type = "cusFeature",
field = "usage",
delta = toDeduct
})
end
table.insert(stateChanges, {
type = "cusFeature",
field = "granted_balance",
field = "current_balance",
delta = -toDeduct
})
else
table.insert(stateChanges, {
type = "breakdown",
index = index,
field = "usage",
delta = toDeduct
})
table.insert(stateChanges, {
type = "cusFeature",
field = "usage",
delta = toDeduct
})
remaining = remaining - toDeduct
end
table.insert(stateChanges, {
type = "cusFeature",
field = "current_balance",
delta = -toDeduct
})
remaining = remaining - toDeduct
end
end
else
@@ -266,47 +280,59 @@ local function deductFromCurrentBalance(cusFeature, amount, adjustGrantedBalance
local topLevelCurrentBalance = cusFeature.current_balance or 0
-- For refunds (negative amount), always apply. For deductions, only if balance > 0
if remaining < 0 or topLevelCurrentBalance > 0 then
-- Calculate how much we can deduct (ensure current_balance never goes below 0)
local maxDeductible = topLevelCurrentBalance
local toDeduct = math.min(remaining, maxDeductible)
-- Collect Redis deltas
table.insert(deltas, {key = cusFeature._key, field = "current_balance", delta = -toDeduct})
-- Either increment usage or decrement granted_balance based on flag
if adjustGrantedBalance then
table.insert(deltas, {key = cusFeature._key, field = "granted_balance", delta = -toDeduct})
local toDeduct
if remaining < 0 then
-- Refund/Negative track: Cap at granted_balance
local grantedBalance = cusFeature.granted_balance or 0
local maxAddable = math.max(0, grantedBalance - topLevelCurrentBalance)
local toAdd = math.min(-remaining, maxAddable)
toDeduct = -toAdd
else
table.insert(deltas, {key = cusFeature._key, field = "usage", delta = toDeduct})
-- Deduction: Cap at current_balance
-- Calculate how much we can deduct (ensure current_balance never goes below 0)
local maxDeductible = topLevelCurrentBalance
toDeduct = math.min(remaining, maxDeductible)
end
-- Collect state changes
local newBalance = topLevelCurrentBalance - toDeduct
-- Ensure current_balance never goes below 0
if newBalance < 0 then
newBalance = 0
end
table.insert(stateChanges, {
type = "cusFeature",
field = "current_balance",
newValue = newBalance
})
if adjustGrantedBalance then
if toDeduct ~= 0 then
-- Collect Redis deltas
table.insert(deltas, {key = cusFeature._key, field = "current_balance", delta = -toDeduct})
-- Either increment usage or decrement granted_balance based on flag
if adjustGrantedBalance then
table.insert(deltas, {key = cusFeature._key, field = "granted_balance", delta = -toDeduct})
else
table.insert(deltas, {key = cusFeature._key, field = "usage", delta = toDeduct})
end
-- Collect state changes
local newBalance = topLevelCurrentBalance - toDeduct
-- Ensure current_balance never goes below 0
if newBalance < 0 then
newBalance = 0
end
table.insert(stateChanges, {
type = "cusFeature",
field = "granted_balance",
delta = -toDeduct
})
else
table.insert(stateChanges, {
type = "cusFeature",
field = "usage",
delta = toDeduct
field = "current_balance",
newValue = newBalance
})
if adjustGrantedBalance then
table.insert(stateChanges, {
type = "cusFeature",
field = "granted_balance",
delta = -toDeduct
})
else
table.insert(stateChanges, {
type = "cusFeature",
field = "usage",
delta = toDeduct
})
end
remaining = remaining - toDeduct
end
remaining = remaining - toDeduct
end
end
@@ -1107,7 +1133,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
end
-- Step 3: Check if request can succeed based on overage behavior
if remainingAmount ~= 0 and overageBehavior == "reject" then
if remainingAmount > 0 and overageBehavior == "reject" then
return {
success = false,
error = "INSUFFICIENT_BALANCE"

View File

@@ -0,0 +1,21 @@
import * as Sentry from "@sentry/bun";
import type { AutumnContext } from "../../honoUtils/HonoEnv";
export const setSentryTags = ({
ctx,
customerId,
messageId,
}: {
ctx: AutumnContext;
customerId?: string;
messageId?: string;
}) => {
Sentry.setTags({
org_id: ctx.org.id,
org_slug: ctx.org.slug,
env: ctx.env,
request_id: ctx.id,
customer_id: customerId,
message_id: messageId,
});
};

View File

@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/bun";
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { setSentryTags } from "../external/sentry/sentryUtils";
export const parseCustomerIdFromUrl = ({
url,
@@ -137,9 +137,9 @@ export const analyticsMiddleware = async (c: Context<HonoEnv>, next: Next) => {
ctx.logger.info(`${method} ${c.req.path} (${ctx.org?.slug})`);
Sentry.setUser({
...reqContext,
body: undefined,
setSentryTags({
ctx,
customerId,
});
// Execute the request

View File

@@ -27,6 +27,7 @@ export const handleCheck = createRoute({
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
const {
customer_id,
feature_id,

View File

@@ -1,8 +1,7 @@
import { ErrCode, type EventInsert, events } from "@autumn/shared";
import { ErrCode, type EventInsert, events, RecaseError } from "@autumn/shared";
import { and, desc, eq } from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
export class EventService {
static async insert({ db, event }: { db: DrizzleCli; event: EventInsert }) {
@@ -12,24 +11,14 @@ export class EventService {
.values(event as any)
.returning();
if (results.length === 0) {
throw new RecaseError({
message: "Failed to insert event",
code: ErrCode.CreateEventFailed,
data: results,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
return results[0];
return results?.[0];
} catch (error: any) {
if (error.code === "23505") {
throw new RecaseError({
message:
"Event (event_name, customer_id, idempotency_key) already exists.",
code: ErrCode.DuplicateEvent,
// data: error,
statusCode: StatusCodes.BAD_REQUEST,
statusCode: StatusCodes.CONFLICT,
});
} else throw error;
}

View File

@@ -123,26 +123,6 @@ export const runRedisDeduction = async ({
}: RunRedisDeductionParams): Promise<RunRedisDeductionResult> => {
const { org, env, skipCache } = ctx;
// const hasContUseFeature = featureDeductions.some((deduction) =>
// isContUseFeature({ feature: deduction.feature }),
// );
// 1. Check for idempotency key
if (trackParams.idempotency_key) {
return {
fallback: true,
code: "idempotency_key",
};
}
// // 2. Check for continuous use feature
// if (hasContUseFeature) {
// return {
// fallback: true,
// code: "allocated_feature",
// };
// }
if (query.skip_cache || skipCache) {
return {
fallback: true,
@@ -150,22 +130,22 @@ export const runRedisDeduction = async ({
};
}
const {
customer_id: customerId,
customer_data: customerData,
entity_id: entityId,
entity_data: entityData,
} = trackParams;
const { apiCustomer } = await getOrCreateApiCustomer({
ctx,
customerId,
customerData,
entityId,
entityData,
});
const result = await tryRedisWrite<RunRedisDeductionResult>(async () => {
const {
customer_id: customerId,
customer_data: customerData,
entity_id: entityId,
entity_data: entityData,
} = trackParams;
const { apiCustomer } = await getOrCreateApiCustomer({
ctx,
customerId,
customerData,
entityId,
entityData,
});
// Map feature deductions to the format expected by batching manager
const mappedDeductions = featureDeductions.map(
({ feature, deduction }) => ({

View File

@@ -7,9 +7,12 @@ import {
type TrackParams,
type TrackResponseV2,
} from "@autumn/shared";
import { db } from "../../../db/initDrizzle";
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
import { EventService } from "../../api/events/EventService";
import { CusService } from "../../customers/CusService";
import { runRedisDeduction } from "./redisTrackUtils/runRedisDeduction";
import { constructEvent, type EventInfo } from "./trackUtils/eventUtils";
import { executePostgresTracking } from "./trackUtils/executePostgresTracking";
import type { FeatureDeduction } from "./trackUtils/getFeatureDeductions";
@@ -32,6 +35,41 @@ export const runTrack = async ({
});
}
const eventInfo: EventInfo = {
event_name: body.feature_id || body.event_name || "",
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
};
// If idempotency key is provided, insert event first
if (body.idempotency_key) {
const customer = await CusService.getFull({
db,
idOrInternalId: body.customer_id,
orgId: ctx.org.id,
env: ctx.env,
entityId: body.entity_id,
});
const newEvent = constructEvent({
ctx,
eventInfo,
internalCustomerId: customer?.internal_id ?? "",
internalEntityId: customer?.entity?.internal_id ?? undefined,
customerId: body.customer_id,
entityId: body.entity_id,
});
await EventService.insert({
db,
event: newEvent,
});
body.skip_event = true;
}
const { fallback, balances } = await runRedisDeduction({
ctx,
query: {
@@ -41,13 +79,7 @@ export const runTrack = async ({
trackParams: body,
featureDeductions,
overageBehavior: body.overage_behavior || "cap",
eventInfo: {
event_name: body.feature_id || body.event_name || "",
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
eventInfo,
});
let response: TrackResponseV2;

View File

@@ -76,13 +76,15 @@ export const executePostgresTracking = async ({
entityId: body.entity_id,
deductions: featureDeductions,
overageBehaviour: body.overage_behavior,
eventInfo: {
event_name: body.feature_id || body.event_name || "",
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
eventInfo: body.idempotency_key
? undefined
: {
event_name: body.feature_id || body.event_name || "",
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
refreshCache: true,
fullCus,
skipAdditionalBalance: true,

View File

@@ -10,6 +10,7 @@ import { handleListCustomers } from "./handlers/handleListCustomers.js";
import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js";
import { handleTransferProductV2 } from "./handlers/handleTransferProductV2.js";
import { handleUpdateBalancesV2 } from "./handlers/handleUpdateBalancesV2.js";
import { handleUpdateCusEntitlementV2 } from "./handlers/handleUpdateCusEntitlementV2.js";
import { handleUpdateCustomerV2 } from "./handlers/handleUpdateCustomerV2.js";
export const expressCusRouter = express.Router();
@@ -33,3 +34,7 @@ cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal);
// Legacy...
cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2);
cusRouter.post(
"/:customer_id/entitlements/:customer_entitlement_id",
...handleUpdateCusEntitlementV2,
);

View File

@@ -0,0 +1,74 @@
import { InternalError, notNullish } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { runDeductionTx } from "../../balances/track/trackUtils/runDeductionTx";
import { CusService } from "../CusService";
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService";
import { deleteCachedApiCustomer } from "../cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
export const handleUpdateCusEntitlementV2 = createRoute({
body: z.object({
balance: z.number(),
next_reset_at: z.number().nullish(),
entity_id: z.string().nullish(),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { customer_id, customer_entitlement_id } = c.req.param();
const { balance, next_reset_at, entity_id } = c.req.valid("json");
const { db, org, env } = ctx;
const fullCus = await CusService.getFull({
db,
idOrInternalId: customer_id,
orgId: org.id,
env,
});
const cusEnt = fullCus.customer_products
.flatMap((cp) => cp.customer_entitlements)
.find((ce) => ce.id === customer_entitlement_id);
if (!cusEnt) {
throw new InternalError({
message: `[update cus entitlement] Customer entitlement not found: ${customer_entitlement_id}`,
});
}
await runDeductionTx({
ctx,
customerId: customer_id,
entityId: entity_id ?? undefined,
deductions: [
{
feature: cusEnt.entitlement.feature,
deduction: 0,
targetBalance: balance,
},
],
skipAdditionalBalance: true,
alterGrantedBalance: true,
sortParams: {
cusEntId: customer_entitlement_id,
},
refreshCache: false,
});
if (notNullish(next_reset_at) && next_reset_at !== cusEnt.next_reset_at) {
await CusEntService.update({
db,
id: customer_entitlement_id,
updates: {
next_reset_at,
},
});
}
await deleteCachedApiCustomer({
orgId: ctx.org.id,
env: ctx.env,
customerId: customer_id,
source: "handleUpdateBalance",
});
return c.json({ success: true });
},
});

View File

@@ -47,7 +47,7 @@ export const handleUpdateEntitlement = async (req: any, res: any) => {
const { customer_entitlement_id } = req.params;
const { balance, next_reset_at, entity_id } = req.body;
if (isNaN(parseFloat(balance))) {
if (Number.isNaN(parseFloat(balance))) {
throw new RecaseError({
message: "Invalid balance",
code: ErrCode.InvalidRequest,

View File

@@ -3,9 +3,10 @@ import {
ErrCode,
FeatureNotFoundError,
type FullCustomer,
RecaseError,
} from "@autumn/shared";
import { EntityService } from "@/internal/api/entities/EntityService.js";
import RecaseError from "@/utils/errorUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { ExtendedRequest } from "../../../../utils/models/Request.js";
import { CusService } from "../../../customers/CusService.js";

View File

@@ -1,59 +0,0 @@
/** biome-ignore-all lint/complexity/noStaticOnlyClass: shush */
import {
ErrCode,
type IdempotentOperation,
type InsertIdempotentOperation,
idempotency,
RecaseError,
} from "@autumn/shared";
import { sqlNow } from "@shared/db/utils.js";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export class IdempotencyService {
static async create({
db,
data,
}: {
db: DrizzleCli;
data: InsertIdempotentOperation;
}) {
return await db
.insert(idempotency as any)
.values(data)
.returning();
}
static async get({
db,
id,
}: {
db: DrizzleCli;
id: string;
}): Promise<IdempotentOperation | null> {
const result = await db.query.idempotency.findFirst({
where: (idempotency, { eq, gt }) =>
eq(idempotency.id, id) && gt(idempotency.expires_at, sqlNow),
});
return result ?? null;
}
static async validate({
db,
id,
}: {
db: DrizzleCli;
id: string;
}): Promise<void> {
const idempotency = await IdempotencyService.get({ db, id });
if (idempotency === null) return;
else {
throw new RecaseError({
message: "Idempotency key already exists",
code: ErrCode.IdempotencyKeyAlreadyExists,
statusCode: StatusCodes.CONFLICT,
});
}
}
}

View File

@@ -1,4 +1,4 @@
import * as Sentry from "@sentry/bun";
import { setSentryTags } from "../external/sentry/sentryUtils";
const handleResFinish = (req: any, res: any) => {
const skipUrls = ["/v1/customers/all/search"];
@@ -42,20 +42,22 @@ const parseCustomerIdFromUrl = (url: string): string | undefined => {
};
export const analyticsMiddleware = async (req: any, res: any, next: any) => {
const customerId =
req?.body?.customer_id || parseCustomerIdFromUrl(req.originalUrl);
const reqContext = {
org_id: req.org?.id,
org_slug: req.org?.slug,
env: req.env,
authType: req.authType,
body: req.body,
customer_id:
req?.body?.customer_id || parseCustomerIdFromUrl(req.originalUrl),
customer_id: customerId,
user_id: req.userId || null,
};
Sentry.setUser({
...reqContext,
body: undefined,
setSentryTags({
ctx: req,
customerId,
});
if (req.span) {

View File

@@ -1,7 +1,5 @@
await import("../sentry.js");
import { AuthType } from "@autumn/shared";
import {
DeleteMessageCommand,
type Message,
@@ -20,6 +18,7 @@ import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigration
import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js";
import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js";
import { generateId } from "@/utils/genUtils.js";
import { setSentryTags } from "../external/sentry/sentryUtils.js";
import { createWorkerContext } from "./createWorkerContext.js";
import { QUEUE_URL, sqs } from "./initSqs.js";
import { JobName } from "./JobName.js";
@@ -100,12 +99,8 @@ const processMessage = async ({
});
if (ctx) {
Sentry.setUser({
org_id: ctx.org?.id,
org_slug: ctx.org?.slug,
env: ctx.env,
authType: AuthType.Worker,
jobName: job.name,
setSentryTags({
ctx,
messageId: message.MessageId,
});
}

View File

@@ -8,7 +8,6 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import type Stripe from "stripe";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
@@ -51,7 +50,6 @@ const premiumProduct = constructProduct({
const testCase = "downgrade5";
describe(`${chalk.yellowBright(`${testCase}: testing basic downgrade (paid to paid)`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt();
let testClockId: string;
let stripeCli: Stripe;

View File

@@ -4,13 +4,13 @@ import {
ProductItemInterval,
type ProductV2,
} from "@autumn/shared";
import chalk from "chalk";
import { addWeeks } from "date-fns";
import { defaultApiVersion } from "@tests/constants.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { addWeeks } from "date-fns";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
@@ -37,8 +37,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage pro
const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
let testClockId: string;
const curUnix = new Date().getTime();
beforeAll(async () => {
await initProductsV0({
ctx,

View File

@@ -45,13 +45,12 @@ const basic = constructRawProduct({
],
});
describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
const customerId = "temp";
describe(`${chalk.yellowBright("newVersion4: Testing add ons")}`, () => {
const customerId = "newVersion4";
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
beforeAll(async () => {
const result = await initCustomerV3({
await initCustomerV3({
ctx,
customerId,
customerData: {},
@@ -109,7 +108,7 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
product_id: pro.id,
});
expect(res.current_product?.scenario).toBe("renew");
expect(res.product?.scenario).toBe("renew");
await autumn.attach({
customer_id: customerId,

View File

@@ -1,13 +1,13 @@
import { LegacyVersion } from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import { addHours, addMonths, addWeeks } from "date-fns";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { LegacyVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { addHours, addMonths, addWeeks } from "date-fns";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";

View File

@@ -24,7 +24,7 @@ export const getCustomerEvents = async ({
customerId: string;
}) => {
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
console.log("Fetching customer with autumn id");
const customer = await autumnV2.customers.get(customerId, {
with_autumn_id: true,
});

View File

@@ -62,8 +62,8 @@ describe(`${chalk.yellowBright(`${testCase}: Tracking allocated feature with con
const values = [];
for (let i = 0; i < 10; i++) {
const randomVal =
Math.floor(Math.random() * 5) * (Math.random() < 0.3 ? -1 : 1);
const randomVal = Math.floor(Math.random() * 5);
// * (Math.random() < 0.3 ? -1 : 1);
promises.push(
autumn.track({
customer_id: customerId,
@@ -71,8 +71,17 @@ describe(`${chalk.yellowBright(`${testCase}: Tracking allocated feature with con
value: randomVal,
}),
);
// Calculate expected balance with constraint: balance can never exceed includedUsage
// (i.e., usage can never go below 0)
const potentialBalance = startingBalance - randomVal;
const cappedBalance = Math.min(
potentialBalance,
userItem.included_usage,
);
totalUsage += randomVal;
startingBalance -= randomVal;
startingBalance = cappedBalance;
values.push(randomVal);
numberOfTracks++;

View File

@@ -113,9 +113,9 @@ describe(`${chalk.yellowBright(`${testCase}: Testing negative values (refunds/cr
const usage = customer.features[TestFeature.Messages].usage;
// Balance should increase by full 50
expect(balance).toBe(130);
expect(balance).toBe(100);
// Usage should decrease by 50 (from 20 to -30)
expect(usage).toBe(-30);
expect(usage).toBe(0);
});
test("should reflect large refund in non-cached customer after 2s", async () => {
@@ -129,7 +129,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing negative values (refunds/cr
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
expect(balance).toBe(130);
expect(usage).toBe(-30);
expect(balance).toBe(100);
expect(usage).toBe(0);
});
});

View File

@@ -1,14 +1,17 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { ApiVersion, ErrCode } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectAutumnError } from "../../../utils/expectUtils/expectErrUtils";
import { timeout } from "../../../utils/genUtils";
import { getCustomerEvents } from "../../testBalanceUtils";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
@@ -72,6 +75,11 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
expect(balance).toBe(expectedBalance);
expect(usage).toBe(deductValue);
const eventsList = await getCustomerEvents({ customerId });
expect(eventsList).toHaveLength(1);
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey);
expect(eventsList?.[0].value).toBe(deductValue);
});
test("should reject second track with same idempotency key", async () => {
@@ -81,28 +89,35 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
// Get balance before attempting duplicate track
const customerBefore = await autumnV1.customers.get(customerId);
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
// This should fail or be rejected due to duplicate idempotency key
let errorThrown = false;
try {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
idempotency_key: idempotencyKey,
});
} catch (error) {
errorThrown = true;
// Optionally check error type/message
}
expect(errorThrown).toBe(true);
await expectAutumnError({
errCode: ErrCode.DuplicateEvent,
func: async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
idempotency_key: idempotencyKey,
});
},
});
// Balance should remain unchanged
const customerAfter = await autumnV1.customers.get(customerId);
const balanceAfter = customerAfter.features[TestFeature.Messages].balance;
const balanceAfter = customerAfter.features[TestFeature.Messages].balance;
expect(balanceAfter).toBe(balanceBefore);
await timeout(2000);
const customerAfter2 = await autumnV1.customers.get(customerId, {
skip_cache: "true",
});
const balanceAfter2 = customerAfter2.features[TestFeature.Messages].balance;
expect(balanceAfter2).toBe(balanceBefore);
const eventsList = await getCustomerEvents({ customerId });
expect(eventsList).toHaveLength(1);
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey);
});
test("should process track with different idempotency key", async () => {

View File

@@ -1,5 +1,9 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type TrackResponseV2 } from "@autumn/shared";
import {
type ApiCustomer,
ApiVersion,
type TrackResponseV2,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
@@ -8,6 +12,7 @@ import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { timeout } from "../../../utils/genUtils";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
@@ -62,16 +67,46 @@ describe(`${chalk.yellowBright("track-negative1: track negative on meterd featur
value: deductValue,
});
expect(trackRes.balance).toBeDefined();
expect(trackRes.balance?.feature_id).toBe(TestFeature.Messages);
expect(trackRes.balance?.current_balance).toBe(100 - deductValue);
expect(trackRes.balance?.usage).toBe(deductValue);
expect(trackRes.balance).toMatchObject({
granted_balance: 100,
current_balance: 100,
purchased_balance: 0,
usage: 0,
});
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
test("should deduct first, then deduct negative value and have correct balance", async () => {
const deductValue1 = 50;
const deductValue2 = -37.89;
expect(balance).toBe(100 - deductValue);
expect(usage).toBe(deductValue);
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue1,
});
const trackRes2: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue2,
});
expect(trackRes2.balance).toMatchObject({
granted_balance: 100,
current_balance: 100 - deductValue1 - deductValue2,
purchased_balance: 0,
usage: deductValue1 + deductValue2,
});
await timeout(2000);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
const balance = customer.balances[TestFeature.Messages];
expect(balance).toMatchObject({
granted_balance: 100,
current_balance: 100 - deductValue1 - deductValue2,
purchased_balance: 0,
usage: deductValue1 + deductValue2,
});
});
});

View File

@@ -0,0 +1,152 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCustomer,
ApiVersion,
type TrackResponseV2,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { timeout } from "../../../utils/genUtils";
const userItem = constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 5,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [userItem],
});
const testCase = "track-negative3";
describe(`${chalk.yellowBright("track-negative3: track negative on free allocated feature, should cap with granted balance")}`, () => {
const customerId = "track-negative3";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should track positive into 'overage' ", async () => {
const trackValue = 8;
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: trackValue,
});
expect(trackRes.balance).toBeDefined();
expect(trackRes.balance).toMatchObject({
current_balance: 0,
purchased_balance: 3,
usage: trackValue,
});
});
test("should track negative and cap at granted balance", async () => {
// Currently at -3
const trackValue = -20;
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: trackValue,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 5,
current_balance: 5,
purchased_balance: 0,
usage: 0,
});
});
test("non-cached customer should reflect changes", async () => {
await timeout(2000);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customer.balances[TestFeature.Users]).toMatchObject({
granted_balance: 5,
current_balance: 5,
purchased_balance: 0,
usage: 0,
});
});
test("should update balance and cap at granted balance", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
current_balance: 10,
});
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
expect(balance).toMatchObject({
granted_balance: 10,
current_balance: 10,
purchased_balance: 0,
usage: 0,
});
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 5,
});
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: -100,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 10,
current_balance: 10,
purchased_balance: 0,
usage: 0,
});
});
test("non-cached customer should reflect changes", async () => {
await timeout(2000);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customer.balances[TestFeature.Users]).toMatchObject({
granted_balance: 10,
current_balance: 10,
purchased_balance: 0,
usage: 0,
});
});
});

View File

@@ -59,7 +59,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
console.log("DB:", {
bal: cusEnt?.balance,
add_bal: cusEnt?.additional_balance,
add_grant: cusEnt?.additional_granted_balance,
add_grant: cusEnt?.adjustment,
});
console.log("API:", {
granted: balance.granted_balance,
@@ -111,7 +111,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0); // Unchanged
expect(cusEnt?.additional_balance).toBe(10); // Added
expect(cusEnt?.additional_granted_balance).toBe(10); // Added
expect(cusEnt?.adjustment).toBe(10); // Added
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
@@ -139,7 +139,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0); // Unchanged (all came from add_bal)
expect(cusEnt?.additional_balance).toBe(5); // 10 - 5
expect(cusEnt?.additional_granted_balance).toBe(5); // 10 - 5
expect(cusEnt?.adjustment).toBe(5); // 10 - 5
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
@@ -166,7 +166,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0);
expect(cusEnt?.additional_balance).toBe(0); // Floored
expect(cusEnt?.additional_granted_balance).toBe(0); // 5 - 5
expect(cusEnt?.adjustment).toBe(0); // 5 - 5
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
@@ -203,7 +203,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
// add_bal = 0 + 10 = 10, add_grant = 0 + 10 = 10, balance = -5
expect(cusEnt?.balance).toBe(-5); // Unchanged
expect(cusEnt?.additional_balance).toBe(10);
expect(cusEnt?.additional_granted_balance).toBe(10);
expect(cusEnt?.adjustment).toBe(10);
// Now remove: update to 3
// current = Math.max(0, -5) + 10 = 10
@@ -222,7 +222,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(-5); // Unchanged
expect(cusEnt?.additional_balance).toBe(3); // 10 - 7
expect(cusEnt?.additional_granted_balance).toBe(3); // 10 - 7
expect(cusEnt?.adjustment).toBe(3); // 10 - 7
});
test("CASE D: balances.update from negative to positive preserves paid credits", async () => {
@@ -246,7 +246,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(-5); // Unchanged (still in debt)
expect(cusEnt?.additional_balance).toBe(0); // 3 - 3
expect(cusEnt?.additional_granted_balance).toBe(0); // 3 - 3
expect(cusEnt?.adjustment).toBe(0); // 3 - 3
});
test("CASE E: Track negative to fully return debt", async () => {
@@ -267,7 +267,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0); // -5 + 5 = 0
expect(cusEnt?.additional_balance).toBe(0); // Unchanged
expect(cusEnt?.additional_granted_balance).toBe(0); // Unchanged
expect(cusEnt?.adjustment).toBe(0); // Unchanged
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];

View File

@@ -43,7 +43,9 @@ export default function App() {
email: data.user.email ?? "unknown_email",
name: data.user.name ?? "unknown_name",
id: data.user.id ?? "unknown_user",
orgId: data.session.activeOrganizationId ?? "unknown_org",
});
Sentry.setTags({
org_id: data.session.activeOrganizationId ?? "unknown_org",
});
}
}, [data]);

View File

@@ -92,14 +92,23 @@ function UpdateCusEntitlement({
setUpdateLoading(true);
try {
await axiosInstance.post("/v1/balances/update", {
customer_id: customer.id || customer.internal_id,
feature_id: feature.id,
current_balance: balanceInt,
customer_entitlement_id: cusEnt.id,
entity_id: entityId ?? undefined,
// usage: 0,
});
// await axiosInstance.post("/v1/balances/update", {
// customer_id: customer.id || customer.internal_id,
// feature_id: feature.id,
// current_balance: balanceInt,
// customer_entitlement_id: cusEnt.id,
// entity_id: entityId ?? undefined,
// // usage: 0,
// });
const customerId = customer.id || customer.internal_id;
await axiosInstance.post(
`/v1/customers/${customerId}/entitlements/${cusEnt.id}`,
{
balance: balanceInt,
next_reset_at: updateFields.next_reset_at,
entity_id: entityId ?? undefined,
},
);
toast.success("Entitlement updated successfully");
await refetch();