fix: rewrote track in psql functions

This commit is contained in:
John Yeo
2025-10-30 16:26:32 -07:00
parent f102310542
commit 5ab888a9b8
69 changed files with 4143 additions and 1508 deletions

View File

@@ -8,6 +8,7 @@ if [[ "$1" == *"setup"* ]]; then
MOCHA_PARALLEL=true $MOCHA_SETUP MOCHA_PARALLEL=true $MOCHA_SETUP
fi fi
$MOCHA_CMD 'tests/advanced/rollovers/*.ts'
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ # $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
# 'tests/advanced/coupons/*.ts' \ # 'tests/advanced/coupons/*.ts' \
# 'tests/attach/updateQuantity/*.ts' \ # 'tests/attach/updateQuantity/*.ts' \

View File

@@ -1,15 +1,16 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { schemas as schema } from "@autumn/shared"; import { schemas as schema } from "@autumn/shared";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
export let client = postgres(process.env.DATABASE_URL!); export const client = postgres(process.env.DATABASE_URL!);
export let db = drizzle(client, { schema }); export const db = drizzle(client, { schema });
export const initDrizzle = (params?: { maxConnections?: number }) => { export const initDrizzle = (params?: { maxConnections?: number }) => {
let maxConnections = params?.maxConnections || 10; const maxConnections = params?.maxConnections;
const client = postgres(process.env.DATABASE_URL!, { const client = postgres(process.env.DATABASE_URL!, {
max: maxConnections, max: maxConnections,
}); });

View File

@@ -13,6 +13,7 @@ import {
type LegacyVersion, type LegacyVersion,
type OrgConfig, type OrgConfig,
type RewardRedemption, type RewardRedemption,
type TrackParams,
} from "@autumn/shared"; } from "@autumn/shared";
import type { import type {
CancelParams, CancelParams,
@@ -21,7 +22,6 @@ import type {
CheckParams, CheckParams,
CheckResult, CheckResult,
Customer, Customer,
TrackParams,
UsageParams, UsageParams,
} from "autumn-js"; } from "autumn-js";
@@ -470,7 +470,7 @@ export class AutumnInt {
}, },
}; };
track = async (params: TrackParams & { timestamp?: number }) => { track = async (params: TrackParams) => {
const data = await this.post(`/track`, params); const data = await this.post(`/track`, params);
return data; return data;
}; };

View File

@@ -18,12 +18,16 @@ if (process.env.NODE_ENV !== "development") {
} }
import cluster from "node:cluster"; import cluster from "node:cluster";
import { readFileSync } from "node:fs";
import http from "node:http"; import http from "node:http";
import os from "node:os"; import os from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { AppEnv } from "@autumn/shared"; import { AppEnv } from "@autumn/shared";
import { context, trace } from "@opentelemetry/api"; import { context, trace } from "@opentelemetry/api";
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import cors from "cors"; import cors from "cors";
import { sql } from "drizzle-orm";
import express from "express"; import express from "express";
import { client, db } from "./db/initDrizzle.js"; import { client, db } from "./db/initDrizzle.js";
import { CacheManager } from "./external/caching/CacheManager.js"; import { CacheManager } from "./external/caching/CacheManager.js";
@@ -38,11 +42,43 @@ import { auth } from "./utils/auth.js";
import { generateId } from "./utils/genUtils.js"; import { generateId } from "./utils/genUtils.js";
import { checkEnvVars } from "./utils/initUtils.js"; import { checkEnvVars } from "./utils/initUtils.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const tracer = trace.getTracer("express"); const tracer = trace.getTracer("express");
checkEnvVars(); checkEnvVars();
// subscribeToOrgUpdates({ db }); // subscribeToOrgUpdates({ db });
const initializeDatabaseFunctions = async () => {
try {
console.log("Initializing database functions...");
const deductRpcPath = join(
__dirname,
"internal/balances/track/trackUtils/deductRpc",
);
// Load SQL files in order: helpers first, then main function
const sqlFiles = [
"deductFromSingleEntity.sql",
"deductFromAllEntities.sql",
"deductFromRollovers.sql",
"deductAllowance.sql",
];
for (const file of sqlFiles) {
const sqlContent = readFileSync(join(deductRpcPath, file), "utf-8");
await db.execute(sql.raw(sqlContent));
console.log(` ✓ Loaded ${file}`);
}
console.log("Database functions initialized successfully");
} catch (error) {
console.error("Failed to initialize database functions:", error);
throw error;
}
};
const init = async () => { const init = async () => {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
@@ -136,6 +172,9 @@ const init = async () => {
ClickHouseManager.getInstance(), ClickHouseManager.getInstance(),
]); ]);
// Initialize database functions
await initializeDatabaseFunctions();
app.use(async (req: any, res: any, next: any) => { app.use(async (req: any, res: any, next: any) => {
req.env = req.env = req.headers.app_env || AppEnv.Sandbox; req.env = req.env = req.headers.app_env || AppEnv.Sandbox;
req.db = db; req.db = db;

View File

@@ -14,6 +14,7 @@ import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js"; import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js"; import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { handleCheck } from "./internal/api/check/handleCheck.js"; import { handleCheck } from "./internal/api/check/handleCheck.js";
import { handleSetUsage } from "./internal/balances/setUsage/handleSetUsage.js";
import { handleTrack } from "./internal/balances/track/handleTrack.js"; import { handleTrack } from "./internal/balances/track/handleTrack.js";
import { cusRouter } from "./internal/customers/cusRouter.js"; import { cusRouter } from "./internal/customers/cusRouter.js";
import { internalCusRouter } from "./internal/customers/internalCusRouter.js"; import { internalCusRouter } from "./internal/customers/internalCusRouter.js";
@@ -95,6 +96,7 @@ export const createHonoApp = () => {
// API Routes // API Routes
app.post("/v1/events", ...handleTrack); app.post("/v1/events", ...handleTrack);
app.post("/v1/track", ...handleTrack); app.post("/v1/track", ...handleTrack);
app.post("/v1/usage", ...handleSetUsage);
app.post("/v1/entitled", ...handleCheck); app.post("/v1/entitled", ...handleCheck);
app.post("/v1/check", ...handleCheck); app.post("/v1/check", ...handleCheck);

View File

@@ -19,7 +19,6 @@ import { productBetaRouter, productRouter } from "../products/productRouter.js";
import { componentRouter } from "./components/componentRouter.js"; import { componentRouter } from "./components/componentRouter.js";
import { entityRouter } from "./entities/entityRouter.js"; import { entityRouter } from "./entities/entityRouter.js";
// import { checkRouter } from "./entitled/checkRouter.js"; // import { checkRouter } from "./entitled/checkRouter.js";
import { usageRouter } from "./events/usageRouter.js";
import { invoiceRouter } from "./invoiceRouter.js"; import { invoiceRouter } from "./invoiceRouter.js";
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js"; import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js"; import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";
@@ -42,7 +41,6 @@ apiRouter.use("/rewards", rewardRouter);
apiRouter.use("/features", featureRouter); apiRouter.use("/features", featureRouter);
apiRouter.use("/internal_features", internalFeatureRouter); apiRouter.use("/internal_features", internalFeatureRouter);
apiRouter.use("/usage", usageRouter);
apiRouter.use("/entities", entityRouter); apiRouter.use("/entities", entityRouter);
apiRouter.use("/migrations", migrationRouter); apiRouter.use("/migrations", migrationRouter);
@@ -57,6 +55,7 @@ apiRouter.use("/cancel", cancelRouter);
// apiRouter.use("/entitled", checkRouter); // apiRouter.use("/entitled", checkRouter);
// apiRouter.use("/check", checkRouter); // apiRouter.use("/check", checkRouter);
// apiRouter.use("/usage", usageRouter);
// apiRouter.use("/events", eventsRouter); // apiRouter.use("/events", eventsRouter);
// apiRouter.use("/track", eventsRouter); // apiRouter.use("/track", eventsRouter);

View File

@@ -1,9 +1,8 @@
import { ErrCode, EventInsert } from "@autumn/shared"; import { ErrCode, type EventInsert, events } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js"; import { and, desc, eq } from "drizzle-orm";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { events } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import { and, eq, desc } from "drizzle-orm"; import RecaseError from "@/utils/errorUtils.js";
export class EventService { export class EventService {
static async insert({ db, event }: { db: DrizzleCli; event: EventInsert }) { static async insert({ db, event }: { db: DrizzleCli; event: EventInsert }) {
@@ -24,7 +23,7 @@ export class EventService {
return results[0]; return results[0];
} catch (error: any) { } catch (error: any) {
if (error.code == "23505") { if (error.code === "23505") {
throw new RecaseError({ throw new RecaseError({
message: message:
"Event (event_name, customer_id, idempotency_key) already exists.", "Event (event_name, customer_id, idempotency_key) already exists.",
@@ -49,7 +48,7 @@ export class EventService {
env: string; env: string;
limit?: number; limit?: number;
}) { }) {
let results = await db const results = await db
.select({ .select({
id: events.id, id: events.id,
event_name: events.event_name, event_name: events.event_name,

View File

@@ -0,0 +1,123 @@
import {
CusProductStatus,
cusEntToIncludedUsage,
cusProductsToCusEnts,
type Feature,
FeatureType,
getRelevantFeatures,
type SetUsageParams,
sumValues,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { CusService } from "../../customers/CusService.js";
import {
getFeatureBalance,
getUnlimitedAndUsageAllowed,
} from "../../customers/cusProducts/cusEnts/cusEntUtils.js";
import { featureToCreditSystem } from "../../features/creditSystemUtils.js";
import type { FeatureDeduction } from "../track/trackUtils/getFeatureDeductions.js";
// 2. Get deductions for each feature
export const getSetUsageDeductions = async ({
ctx,
setUsageParams,
}: {
ctx: AutumnContext;
setUsageParams: SetUsageParams;
}): Promise<FeatureDeduction[]> => {
const { db, org, env, features: allFeatures } = ctx;
const { value, entity_id } = setUsageParams;
const features = getRelevantFeatures({
features: allFeatures,
featureId: setUsageParams.feature_id,
});
const fullCus = await CusService.getFull({
db: ctx.db,
idOrInternalId: setUsageParams.customer_id,
orgId: ctx.org.id,
env: ctx.env,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
entityId: setUsageParams.entity_id,
});
const cusEnts = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
reverseOrder: org.config?.reverse_deduction_order,
});
const meteredFeature =
features.find((f: Feature) => f.type === FeatureType.Metered) ||
features[0];
const featureDeductions = [];
for (const feature of features) {
let newValue = value;
const { unlimited } = getUnlimitedAndUsageAllowed({
cusEnts,
internalFeatureId: feature.internal_id!,
});
if (unlimited) continue;
if (feature.type === FeatureType.CreditSystem) {
newValue = featureToCreditSystem({
featureId: meteredFeature.id,
creditSystem: feature,
amount: value,
});
}
// If it's set
let deduction = newValue;
const totalAllowance = sumValues(
cusEnts.map((cusEnt) =>
cusEntToIncludedUsage({ cusEnt, entityId: setUsageParams.entity_id }),
),
);
const targetBalance = new Decimal(totalAllowance).sub(value).toNumber();
const totalBalance = getFeatureBalance({
cusEnts,
internalFeatureId: feature.internal_id!,
entityId: entity_id,
})!;
deduction = new Decimal(totalBalance).sub(targetBalance).toNumber();
if (deduction === 0) {
console.log(` - Skipping feature ${feature.id} -- deduction is 0`);
continue;
}
featureDeductions.push({
feature,
deduction,
});
}
featureDeductions.sort((a, b) => {
if (
a.feature.type === FeatureType.CreditSystem &&
b.feature.type !== FeatureType.CreditSystem
) {
return 1;
}
if (
a.feature.type !== FeatureType.CreditSystem &&
b.feature.type === FeatureType.CreditSystem
) {
return -1;
}
return a.feature.id.localeCompare(b.feature.id);
});
return featureDeductions;
};

View File

@@ -0,0 +1,39 @@
import { SetUsageParamsSchema } from "@autumn/shared";
import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
import { runDeductionTx } from "../track/trackUtils/runDeductionTx.js";
import { getSetUsageDeductions } from "./getSetUsageDeductions.js";
export const handleSetUsage = createRoute({
body: SetUsageParamsSchema,
handler: async (c) => {
// 1. Get feature deductions
const body = c.req.valid("json");
const ctx = c.get("ctx");
// Build feature deductions
const featureDeductions = await getSetUsageDeductions({
ctx,
setUsageParams: body,
});
const start = Date.now();
await runDeductionTx({
ctx,
customerId: body.customer_id,
entityId: body.entity_id,
deductions: featureDeductions,
// eventInfo: {
// event_name: body.feature_id || body.event_name!,
// value: body.value ?? 1,
// properties: body.properties,
// timestamp: body.timestamp,
// idempotency_key: body.idempotency_key,
// },
});
const elapsed = Date.now() - start;
ctx.logger.info(`[handleTrack] runDeductionTx ms: ${elapsed}`);
return c.json({ success: true });
},
});

View File

@@ -1,4 +1,8 @@
import { TrackParamsSchema } from "@autumn/shared"; import {
InsufficientBalanceError,
SuccessCode,
TrackParamsSchema,
} from "@autumn/shared";
import { createRoute } from "../../../honoMiddlewares/routeHandler.js"; import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
import { import {
getTrackEventNameDeductions, getTrackEventNameDeductions,
@@ -31,12 +35,14 @@ export const handleTrack = createRoute({
value: body.value, value: body.value,
}); });
try {
const start = Date.now(); const start = Date.now();
await runDeductionTx({ const { fullCus, event } = await runDeductionTx({
ctx, ctx,
customerId: body.customer_id, customerId: body.customer_id,
entityId: body.entity_id, entityId: body.entity_id,
deductions: featureDeductions, deductions: featureDeductions,
overageBehaviour: body.overage_behaviour,
eventInfo: { eventInfo: {
event_name: body.feature_id || body.event_name!, event_name: body.feature_id || body.event_name!,
value: body.value ?? 1, value: body.value ?? 1,
@@ -49,6 +55,28 @@ export const handleTrack = createRoute({
const elapsed = Date.now() - start; const elapsed = Date.now() - start;
ctx.logger.info(`[handleTrack] runDeductionTx ms: ${elapsed}`); ctx.logger.info(`[handleTrack] runDeductionTx ms: ${elapsed}`);
return c.json({ success: true }); const response: any = {
id: event?.id || "",
code: SuccessCode.EventReceived,
customer_id: body.customer_id,
entity_id: body.entity_id,
feature_id: body.feature_id,
event_name: body.event_name,
};
return c.json(response);
} catch (error) {
if (error instanceof InsufficientBalanceError) {
return c.json({
id: "",
code: "insufficient_balance",
customer_id: body.customer_id,
entity_id: body.entity_id,
feature_id: body.feature_id,
event_name: body.event_name,
});
}
throw error;
}
}, },
}); });

View File

@@ -0,0 +1,146 @@
# Balance Deduction System Guide
## Overview
The balance deduction system handles iterative deduction from customer entitlements using PostgreSQL stored functions for atomicity and performance.
## Core Deduction Logic
### Where Deductions Happen
- **Balance Level**: Direct deduction from `customer_entitlements.balance`
- **Entity Balance Level**: Deduction from `customer_entitlements.entities` JSONB field
### Sorting Customer Entitlements
Customer entitlements are sorted **before** deduction to ensure consistent deduction order. Sorting logic is in `sortCusEntsForDeduction.ts` and considers:
- Boolean flags (unlimited, active status)
- Feature types (metered vs license)
- Allowance types (quota vs time-based)
- Dates (expiration, next reset)
- Intervals
- Product types
- Creation dates
**Why?** Ensures predictable deduction order (e.g., expiring credits first, then active subscriptions).
## Key Parameters
### Input Structure
```typescript
{
customer_entitlement_id: string;
credit_cost: number; // Multiplier for credit system features
entity_feature_id: string | null; // If present, deduct from entities
usage_allowed: boolean; // Can balance go negative?
min_balance: number; // Minimum balance limit (e.g., -50)
add_to_adjustment: boolean; // Track adjustment for billing
}
```
### Deduction Behavior
#### 1. **usage_allowed**
- `false`: Balance stops at 0 (default)
- `true`: Balance can go negative (usage-based billing)
#### 2. **min_balance**
- Works with `usage_allowed = true`
- Prevents balance from going below this threshold
- Example: `balance = 100, min_balance = -50` → can deduct up to 150
#### 3. **credit_cost**
- Multiplies deduction amount for credit system features
- Example: Deducting 10 units with `credit_cost = 2` → deducts 20 from balance
#### 4. **add_to_adjustment**
- When `true`, updates `customer_entitlements.adjustment` field
- Tracks cumulative adjustments: `adjustment = adjustment + deducted`
- Used for billing reconciliation (see `handleUpdateBalances.ts`)
## Entity-Scoped Deductions
### Single Entity (entity_id provided)
- Deducts from specific entity in `entities` JSONB
- Example: `entities = { "org1": { "balance": 100 } }`
- Deducts from `entities.org1.balance`
### All Entities (entity_id = null)
- Iterates through each entity key in `entities`
- Deducts sequentially until amount satisfied or all entities exhausted
- Example: Deduct 150 from `{ "org1": { "balance": 100 }, "org2": { "balance": 100 } }`
- Result: `{ "org1": { "balance": 0 }, "org2": { "balance": 50 } }`
## Return Structure
```typescript
{
updates: {
[cusEntId]: {
balance: number;
entities: JSONB;
adjustment: number;
deducted: number;
}
},
remaining: number // Amount that couldn't be deducted
}
```
## Overage Behavior
### reject (default)
- If `remaining > 0`, throws error
- Use when strict balance enforcement required
### cap
- Allows partial deduction
- Returns successfully with `remaining` amount
## Billing Integration
After deduction, system automatically:
1. Calculates negative balance changes
2. Calls `adjustAllowance` for each updated entitlement
3. Bills customer on Stripe if overage increased
4. Rolls back transaction on any error
## Transaction Safety
- All deductions run in `read committed` transaction
- Automatic rollback on any error
- Cache refresh only after successful transaction
- Ensures consistency between DB and Stripe
## SQL Helper Functions
### `deduct_from_single_entity(entities, entity_id, amount, allow_negative, min_balance)`
Deducts from a specific entity's balance in JSONB.
### `deduct_from_all_entities(entities, amount, allow_negative, min_balance)`
Iteratively deducts from all entities in JSONB.
### `deduct_allowance_from_entitlements(sorted_entitlements, amount, target_entity_id)`
Main function that orchestrates the entire deduction process.
## Example Usage
```typescript
await runDeductionTx({
ctx,
customerId: "cus_123",
entityId: "org_456", // Optional
deductions: [
{ feature: feature1, deduction: 100 },
{ feature: feature2, deduction: 50 }
],
overageBehaviour: "reject", // or "cap"
addToAdjustment: false, // true for billing adjustments
eventInfo: { ... } // Optional event tracking
});
```
## Performance Considerations
- PostgreSQL function handles all deduction logic → minimal round trips
- Transaction ensures atomic updates
- Sorting happens in TypeScript (typically <10 entitlements)
- Connection pooling prevents exhaustion under high concurrency

View File

@@ -0,0 +1,81 @@
# Deduction RPC Functions
This directory contains PostgreSQL stored functions for balance deduction operations.
## Files
- `deductFromSingleEntity.sql` - Helper function to deduct from a specific entity balance
- `deductFromAllEntities.sql` - Helper function to iteratively deduct from all entities
- `deductAllowance.sql` - Main function that orchestrates the deduction process
## Versioning Strategy
To ensure safe deployments when changing function signatures:
### Adding a Version Suffix
When modifying a function's parameters or return type:
1. **Increment the version number** in the function name:
```sql
-- Old
CREATE FUNCTION deduct_from_single_entity(...)
-- New
CREATE FUNCTION deduct_from_single_entity_v2(...)
```
2. **Update all callers** to use the new version:
```sql
-- In deductAllowance.sql
FROM deduct_from_single_entity_v2(...)
```
3. **Keep the old version** during deployment to prevent breaking existing instances
4. **Clean up after deployment**:
```sql
-- After confirming new version works in production
DROP FUNCTION IF EXISTS deduct_from_single_entity;
DROP FUNCTION IF EXISTS deduct_from_single_entity_v1;
```
### Why Version Suffixes?
PostgreSQL identifies functions by their signature (name + parameter types). When you:
- Change parameter types (e.g., `numeric` → `bigint`)
- Add/remove parameters
- Change return types
...the `DROP FUNCTION IF EXISTS` with explicit signatures won't match the old function, leading to orphaned functions in the database.
**Versioning solves this by:**
- Creating a new function alongside the old one
- Allowing gradual rollout without breaking existing instances
- Giving you time to verify the new version works before cleanup
### Example Migration
```sql
-- deployment-v1.sql
CREATE FUNCTION process_data_v2(
input jsonb,
new_param text -- Added new parameter
) RETURNS jsonb AS $$
-- new implementation
$$ LANGUAGE plpgsql;
-- After deployment and verification
-- cleanup.sql
DROP FUNCTION IF EXISTS process_data;
DROP FUNCTION IF EXISTS process_data_v1;
```
## Loading Order
Functions are loaded in this order during server startup (see `server/src/index.ts`):
1. Helper functions (dependencies)
2. Main function (depends on helpers)
This ensures all dependencies exist before they're called.

View File

@@ -0,0 +1,170 @@
-- Main function: Deduct allowance from customer entitlements
DROP FUNCTION IF EXISTS deduct_allowance_from_entitlements(jsonb, numeric, text, text[]);
CREATE FUNCTION deduct_allowance_from_entitlements(
sorted_entitlements jsonb,
amount_to_deduct numeric,
target_entity_id text DEFAULT NULL,
rollover_ids text[] DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
remaining_amount numeric := amount_to_deduct;
rollover_deducted numeric := 0;
ent_id text;
credit_cost numeric;
usage_allowed boolean;
min_balance numeric;
add_to_adjustment boolean;
ent_obj jsonb;
current_balance numeric;
current_adjustment numeric;
current_entities jsonb;
has_entity_scope boolean;
new_entities jsonb;
new_balance numeric;
new_adjustment numeric;
deducted numeric;
updates_json jsonb := '{}'::jsonb;
result_json jsonb;
BEGIN
-- Then deduct from entitlements
FOR ent_obj IN SELECT * FROM jsonb_array_elements(sorted_entitlements)
LOOP
EXIT WHEN remaining_amount <= 0;
-- Extract entitlement info
ent_id := ent_obj->>'customer_entitlement_id';
credit_cost := (ent_obj->>'credit_cost')::numeric;
usage_allowed := COALESCE((ent_obj->>'usage_allowed')::boolean, false);
min_balance := (ent_obj->>'min_balance')::numeric;
add_to_adjustment := COALESCE((ent_obj->>'add_to_adjustment')::boolean, false);
has_entity_scope := (ent_obj->>'entity_feature_id') IS NOT NULL;
-- First, deduct from rollovers if this is the first entitlement
IF rollover_ids IS NOT NULL AND array_length(rollover_ids, 1) > 0 AND rollover_deducted = 0 THEN
SELECT * INTO rollover_deducted
FROM deduct_from_rollovers(rollover_ids, remaining_amount, target_entity_id, has_entity_scope);
remaining_amount := remaining_amount - rollover_deducted;
END IF;
-- Fetch entitlement data with row lock
SELECT ce.balance, COALESCE(ce.adjustment, 0), COALESCE(ce.entities, '{}'::jsonb)
INTO current_balance, current_adjustment, current_entities
FROM customer_entitlements ce
WHERE ce.id = ent_id
FOR UPDATE;
-- Handle entity-scoped entitlements
IF has_entity_scope THEN
IF target_entity_id IS NOT NULL THEN
-- Deduct from specific entity
SELECT * INTO new_entities, deducted
FROM deduct_from_single_entity(
current_entities,
target_entity_id,
remaining_amount * credit_cost,
usage_allowed,
min_balance,
add_to_adjustment
);
ELSE
-- Deduct from all entities
SELECT * INTO new_entities, deducted
FROM deduct_from_all_entities(
current_entities,
remaining_amount * credit_cost,
usage_allowed,
min_balance,
add_to_adjustment
);
END IF;
-- Update entities and optionally adjustment
IF deducted != 0 THEN
IF add_to_adjustment THEN
UPDATE customer_entitlements ce
SET entities = new_entities, adjustment = adjustment + deducted
WHERE ce.id = ent_id
RETURNING ce.adjustment INTO new_adjustment;
ELSE
UPDATE customer_entitlements ce
SET entities = new_entities
WHERE ce.id = ent_id
RETURNING ce.adjustment INTO new_adjustment;
END IF;
-- Add to updates
updates_json := jsonb_set(
updates_json,
ARRAY[ent_id],
jsonb_build_object(
'balance', current_balance,
'entities', new_entities,
'adjustment', new_adjustment,
'deducted', deducted
)
);
remaining_amount := remaining_amount - (deducted / credit_cost);
END IF;
-- Handle regular balance
ELSE
-- Calculate deduction respecting min_balance
IF usage_allowed THEN
-- If min_balance is null, allow unlimited deduction
IF min_balance IS NULL THEN
deducted := remaining_amount * credit_cost;
ELSE
deducted := LEAST(remaining_amount * credit_cost, current_balance - min_balance);
END IF;
ELSE
deducted := LEAST(current_balance, remaining_amount * credit_cost);
END IF;
IF deducted != 0 THEN
IF add_to_adjustment THEN
UPDATE customer_entitlements ce
SET balance = balance - deducted, adjustment = adjustment + deducted
WHERE ce.id = ent_id
RETURNING ce.balance, ce.adjustment INTO new_balance, new_adjustment;
ELSE
UPDATE customer_entitlements ce
SET balance = balance - deducted
WHERE ce.id = ent_id
RETURNING ce.balance, ce.adjustment INTO new_balance, new_adjustment;
END IF;
-- Add to updates
updates_json := jsonb_set(
updates_json,
ARRAY[ent_id],
jsonb_build_object(
'balance', new_balance,
'entities', current_entities,
'adjustment', new_adjustment,
'deducted', deducted
)
);
remaining_amount := remaining_amount - (deducted / credit_cost);
END IF;
END IF;
END LOOP;
-- Build final result
result_json := jsonb_build_object(
'updates', updates_json,
'remaining', remaining_amount
);
RETURN result_json;
END;
$$;

View File

@@ -0,0 +1,72 @@
-- Helper: Deduct from all entities iteratively
DROP FUNCTION IF EXISTS deduct_from_all_entities(jsonb, numeric, boolean, numeric, boolean);
CREATE FUNCTION deduct_from_all_entities(
entities_json jsonb,
amount numeric,
allow_negative boolean DEFAULT false,
min_balance numeric DEFAULT 0,
track_adjustment boolean DEFAULT false
)
RETURNS TABLE(updated_entities jsonb, total_deducted numeric)
LANGUAGE plpgsql
AS $$
DECLARE
remaining numeric := amount;
entity_key text;
entity_balance numeric;
entity_adjustment numeric;
deduct_amount numeric;
new_balance numeric;
new_adjustment numeric;
new_entities jsonb := entities_json;
total_deducted numeric := 0;
BEGIN
FOR entity_key IN SELECT jsonb_object_keys(entities_json)
LOOP
EXIT WHEN remaining <= 0;
entity_balance := COALESCE((new_entities->entity_key->>'balance')::numeric, 0);
entity_adjustment := COALESCE((new_entities->entity_key->>'adjustment')::numeric, 0);
-- Calculate deduction respecting min_balance
IF allow_negative THEN
-- If min_balance is null, allow unlimited deduction
IF min_balance IS NULL THEN
deduct_amount := remaining;
ELSE
-- Can go negative, but not below min_balance
deduct_amount := LEAST(remaining, entity_balance - min_balance);
END IF;
ELSE
-- Cap at current balance (min 0)
deduct_amount := LEAST(entity_balance, remaining);
END IF;
IF deduct_amount != 0 THEN
new_balance := entity_balance - deduct_amount;
new_entities := jsonb_set(
new_entities,
ARRAY[entity_key, 'balance'],
to_jsonb(new_balance)
);
-- Update adjustment if tracking
IF track_adjustment THEN
new_adjustment := entity_adjustment + deduct_amount;
new_entities := jsonb_set(
new_entities,
ARRAY[entity_key, 'adjustment'],
to_jsonb(new_adjustment)
);
END IF;
remaining := remaining - deduct_amount;
total_deducted := total_deducted + deduct_amount;
END IF;
END LOOP;
RETURN QUERY SELECT new_entities, total_deducted;
END;
$$;

View File

@@ -0,0 +1,137 @@
-- Helper: Deduct from rollovers before deducting from main entitlements
DROP FUNCTION IF EXISTS deduct_from_rollovers(text[], numeric, text);
DROP FUNCTION IF EXISTS deduct_from_rollovers(text[], numeric, text, boolean);
CREATE FUNCTION deduct_from_rollovers(
rollover_ids text[],
amount_to_deduct numeric,
target_entity_id text DEFAULT NULL,
has_entity_scope boolean DEFAULT false
)
RETURNS TABLE(total_deducted numeric)
LANGUAGE plpgsql
AS $$
DECLARE
remaining_amount numeric := amount_to_deduct;
rollover_id text;
current_balance numeric;
current_usage numeric;
current_entities jsonb;
entity_key text;
entity_balance numeric;
entity_usage numeric;
deduct_amount numeric;
new_balance numeric;
new_usage numeric;
new_entities jsonb;
rollover_total_deducted numeric := 0;
BEGIN
-- Loop through rollover IDs in order
FOREACH rollover_id IN ARRAY rollover_ids
LOOP
EXIT WHEN remaining_amount <= 0;
-- Lock and fetch rollover data
SELECT r.balance, COALESCE(r.usage, 0), r.entities
INTO current_balance, current_usage, current_entities
FROM rollovers r
WHERE r.id = rollover_id
FOR UPDATE;
-- Handle entity-scoped rollovers (specific entity)
IF has_entity_scope AND target_entity_id IS NOT NULL THEN
entity_balance := COALESCE((current_entities->target_entity_id->>'balance')::numeric, 0);
entity_usage := COALESCE((current_entities->target_entity_id->>'usage')::numeric, 0);
-- Calculate deduction (always cap at 0)
deduct_amount := LEAST(entity_balance, remaining_amount);
IF deduct_amount > 0 THEN
new_balance := entity_balance - deduct_amount;
new_usage := entity_usage + deduct_amount;
-- Update entity in JSONB
new_entities := jsonb_set(
current_entities,
ARRAY[target_entity_id, 'balance'],
to_jsonb(new_balance)
);
new_entities := jsonb_set(
new_entities,
ARRAY[target_entity_id, 'usage'],
to_jsonb(new_usage)
);
-- Update rollover
UPDATE rollovers r
SET entities = new_entities
WHERE r.id = rollover_id;
remaining_amount := remaining_amount - deduct_amount;
rollover_total_deducted := rollover_total_deducted + deduct_amount;
END IF;
-- Handle entity-scoped rollovers (deduct from all entities)
ELSIF has_entity_scope AND target_entity_id IS NULL THEN
new_entities := current_entities;
deduct_amount := 0;
FOR entity_key IN SELECT jsonb_object_keys(current_entities)
LOOP
EXIT WHEN remaining_amount <= 0;
entity_balance := COALESCE((new_entities->entity_key->>'balance')::numeric, 0);
entity_usage := COALESCE((new_entities->entity_key->>'usage')::numeric, 0);
-- Calculate deduction for this entity (always cap at 0)
deduct_amount := LEAST(entity_balance, remaining_amount);
IF deduct_amount > 0 THEN
new_balance := entity_balance - deduct_amount;
new_usage := entity_usage + deduct_amount;
new_entities := jsonb_set(
new_entities,
ARRAY[entity_key, 'balance'],
to_jsonb(new_balance)
);
new_entities := jsonb_set(
new_entities,
ARRAY[entity_key, 'usage'],
to_jsonb(new_usage)
);
remaining_amount := remaining_amount - deduct_amount;
rollover_total_deducted := rollover_total_deducted + deduct_amount;
END IF;
END LOOP;
-- Update rollover with all entity changes if any deductions occurred
IF new_entities IS DISTINCT FROM current_entities THEN
UPDATE rollovers r
SET entities = new_entities
WHERE r.id = rollover_id;
END IF;
-- Handle regular balance rollovers
ELSE
-- Calculate deduction (always cap at 0)
deduct_amount := LEAST(current_balance, remaining_amount);
IF deduct_amount > 0 THEN
-- Update balance and usage atomically
UPDATE rollovers r
SET balance = balance - deduct_amount, usage = usage + deduct_amount
WHERE r.id = rollover_id;
remaining_amount := remaining_amount - deduct_amount;
rollover_total_deducted := rollover_total_deducted + deduct_amount;
END IF;
END IF;
END LOOP;
RETURN QUERY SELECT rollover_total_deducted;
END;
$$;

View File

@@ -0,0 +1,64 @@
-- Helper: Deduct from a single entity in entities JSONB
DROP FUNCTION IF EXISTS deduct_from_single_entity(jsonb, text, numeric, boolean, numeric, boolean);
CREATE FUNCTION deduct_from_single_entity(
entities_json jsonb,
entity_id text,
amount numeric,
allow_negative boolean DEFAULT false,
min_balance numeric DEFAULT 0,
track_adjustment boolean DEFAULT false
)
RETURNS TABLE(updated_entities jsonb, deducted numeric)
LANGUAGE plpgsql
AS $$
DECLARE
entity_balance numeric;
entity_adjustment numeric;
actual_deduction numeric;
new_balance numeric;
new_adjustment numeric;
new_entities jsonb;
BEGIN
entity_balance := COALESCE((entities_json->entity_id->>'balance')::numeric, 0);
entity_adjustment := COALESCE((entities_json->entity_id->>'adjustment')::numeric, 0);
-- Calculate deduction respecting min_balance
IF allow_negative THEN
-- If min_balance is null, allow unlimited deduction
IF min_balance IS NULL THEN
actual_deduction := amount;
ELSE
-- Can go negative, but not below min_balance
actual_deduction := LEAST(amount, entity_balance - min_balance);
END IF;
ELSE
-- Cap at current balance (min 0)
actual_deduction := LEAST(entity_balance, amount);
END IF;
IF actual_deduction != 0 THEN
new_balance := entity_balance - actual_deduction;
new_entities := jsonb_set(
entities_json,
ARRAY[entity_id, 'balance'],
to_jsonb(new_balance)
);
-- Update adjustment if tracking
IF track_adjustment THEN
new_adjustment := entity_adjustment + actual_deduction;
new_entities := jsonb_set(
new_entities,
ARRAY[entity_id, 'adjustment'],
to_jsonb(new_adjustment)
);
END IF;
ELSE
new_entities := entities_json;
END IF;
RETURN QUERY SELECT new_entities, actual_deduction;
END;
$$;

View File

@@ -1,9 +1,6 @@
import { type Feature, FeatureNotFoundError } from "@autumn/shared"; import { type Feature, FeatureNotFoundError } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { import { getCreditSystemsFromFeature } from "../../../features/creditSystemUtils.js";
getCreditCost,
getCreditSystemsFromFeature,
} from "../../../features/creditSystemUtils.js";
export type FeatureDeduction = { export type FeatureDeduction = {
feature: Feature; feature: Feature;
@@ -43,18 +40,18 @@ export const getTrackFeatureDeductions = ({
deduction: mainFeatureDeduction, deduction: mainFeatureDeduction,
}); });
for (const creditSystem of creditSystems) { // for (const creditSystem of creditSystems) {
const creditSystemDeduction = getCreditCost({ // const creditSystemDeduction = getCreditCost({
featureId: mainFeature.id, // featureId: mainFeature.id,
creditSystem, // creditSystem,
amount: mainFeatureDeduction, // amount: mainFeatureDeduction,
}); // });
featureDeductions.push({ // featureDeductions.push({
feature: creditSystem, // feature: creditSystem,
deduction: creditSystemDeduction, // deduction: creditSystemDeduction,
}); // });
} // }
return featureDeductions; return featureDeductions;
}; };

View File

@@ -1,48 +1,54 @@
import type { Event } from "@autumn/shared";
import { import {
CusProductStatus, CusProductStatus,
cusEntToCusPrice,
cusProductsToCusEnts, cusProductsToCusEnts,
cusProductsToPrices, cusProductsToCusPrices,
FeatureUsageType,
type FullCustomer,
getMaxOverage,
getRelevantFeatures,
InsufficientBalanceError,
InternalError,
notNullish,
nullish,
updateCusEntInFullCus,
} from "@autumn/shared"; } from "@autumn/shared";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { DrizzleCli } from "../../../../db/initDrizzle.js"; import type { DrizzleCli } from "../../../../db/initDrizzle.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { handleThresholdReached } from "../../../../trigger/handleThresholdReached.js"; import { adjustAllowance } from "../../../../trigger/adjustAllowance.js";
import {
deductAllowanceFromCusEnt,
deductFromUsageBasedCusEnt,
} from "../../../../trigger/updateBalanceTask.js";
import { EventService } from "../../../api/events/EventService.js"; import { EventService } from "../../../api/events/EventService.js";
import { CusService } from "../../../customers/CusService.js"; import { CusService } from "../../../customers/CusService.js";
import { refreshCusCache } from "../../../customers/cusCache/updateCachedCus.js"; import { refreshCusCache } from "../../../customers/cusCache/updateCachedCus.js";
import { deductFromApiCusRollovers } from "../../../customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; import {
getTotalNegativeBalance,
getUnlimitedAndUsageAllowed,
} from "../../../customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCreditCost } from "../../../features/creditSystemUtils.js";
import { constructEvent, type EventInfo } from "./eventUtils.js"; import { constructEvent, type EventInfo } from "./eventUtils.js";
import type { FeatureDeduction } from "./getFeatureDeductions.js"; import type { FeatureDeduction } from "./getFeatureDeductions.js";
import { validateDeductionPossible } from "./validateDeductionPossible.js";
export type DeductionTxParams = { export type DeductionTxParams = {
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
entityId?: string; entityId?: string;
deductions: FeatureDeduction[]; deductions: FeatureDeduction[];
eventInfo: EventInfo; eventInfo?: EventInfo;
overageBehaviour?: "cap" | "reject";
addToAdjustment?: boolean;
}; };
// const { cusEnts, cusPrices } = await getCusEntsInFeatures({
// customer,
// internalFeatureIds: features.map((f) => f.internal_id!),
// logger,
// reverseOrder: org.config?.reverse_deduction_order,
// });
const deductFromCusEnts = async ({ const deductFromCusEnts = async ({
ctx, ctx,
customerId, customerId,
entityId, entityId,
deductions, deductions,
overageBehaviour = "cap",
addToAdjustment = false,
}: DeductionTxParams) => { }: DeductionTxParams) => {
const { db, org, env } = ctx; const { db, org, env } = ctx;
const fullCus = await CusService.getFull({
const customer = await CusService.getFull({
db, db,
idOrInternalId: customerId, idOrInternalId: customerId,
orgId: org.id, orgId: org.id,
@@ -52,149 +58,207 @@ const deductFromCusEnts = async ({
withSubs: true, withSubs: true,
}); });
const printLogs = false;
if (printLogs) {
console.log(
`Deductions: `,
deductions.map((d) => ({
feature_id: d.feature.id,
deduction: d.deduction,
})),
);
}
// Need to deduct from customer entitlement...
for (const deduction of deductions) {
const { feature, deduction: toDeduct } = deduction;
const relevantFeatures = getRelevantFeatures({
features: ctx.features,
featureId: feature.id,
});
const cusEnts = cusProductsToCusEnts({ const cusEnts = cusProductsToCusEnts({
cusProducts: customer.customer_products, cusProducts: fullCus.customer_products,
featureIds: deductions.map((d) => d.feature.id), featureIds: relevantFeatures.map((f) => f.id),
reverseOrder: org.config?.reverse_deduction_order, reverseOrder: org.config?.reverse_deduction_order,
}); });
const cusPrices = cusProductsToPrices({ const { unlimited } = getUnlimitedAndUsageAllowed({
cusProducts: cusEnts.map((cusEnt) => cusEnt.customer_product),
});
if (cusEnts.length === 0) return;
validateDeductionPossible({ cusEnts, deductions, entityId });
const originalCusEnts = structuredClone(cusEnts);
for (const obj of deductions) {
const { feature, deduction } = obj;
let toDeduct = deduction;
for (const cusEnt of cusEnts) {
if (cusEnt.entitlement.internal_feature_id !== feature.internal_id) {
continue;
}
toDeduct = await deductFromApiCusRollovers({
toDeduct,
cusEnt,
deductParams: {
db,
feature,
env,
entity: customer.entity ? customer.entity : undefined,
},
});
if (toDeduct === 0) continue;
toDeduct = await deductAllowanceFromCusEnt({
toDeduct,
cusEnt,
deductParams: {
db,
feature,
env,
org,
cusPrices: cusPrices as any[],
customer,
entity: customer.entity,
},
featureDeductions: deductions,
willDeductCredits: true,
setZeroAdjustment: true,
});
}
if (toDeduct !== 0) {
await deductFromUsageBasedCusEnt({
toDeduct,
cusEnts, cusEnts,
deductParams: { internalFeatureId: feature.internal_id!,
db, });
feature,
env, if (cusEnts.length === 0 || unlimited) continue;
org,
cusPrices: cusPrices as any[], const cusEntInput = cusEnts.map((ce) => {
customer, const creditCost = getCreditCost({
entity: customer.entity, featureId: feature.id,
}, creditSystem: ce.entitlement.feature,
setZeroAdjustment: true, });
const maxOverage = getMaxOverage({ cusEnt: ce });
const cusPrice = cusEntToCusPrice({ cusEnt: ce });
const isFreeAllocated =
ce.entitlement.feature.config?.usage_type ===
FeatureUsageType.Continuous && nullish(cusPrice);
return {
customer_entitlement_id: ce.id,
credit_cost: creditCost,
entity_feature_id: ce.entitlement.entity_feature_id,
usage_allowed: ce.usage_allowed || isFreeAllocated,
min_balance: notNullish(maxOverage) ? -maxOverage : undefined,
add_to_adjustment: addToAdjustment,
};
});
// Collect and sort rollovers by expires_at (oldest first)
const sortedRollovers = cusEnts
.flatMap((ce) => ce.rollovers || [])
.sort((a, b) => {
if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at;
if (a.expires_at && !b.expires_at) return -1;
if (!a.expires_at && b.expires_at) return 1;
return 0;
});
const rolloverIds = sortedRollovers.map((r) => r.id);
// Call the stored function to deduct from entitlements with credit costs
const result = await db.execute(
sql`SELECT * FROM deduct_allowance_from_entitlements(
${JSON.stringify(cusEntInput)}::jsonb,
${toDeduct},
${entityId || null},
${rolloverIds.length > 0 ? sql.raw(`ARRAY[${rolloverIds.map((id) => `'${id}'`).join(",")}]`) : null}
)`,
);
// Parse the JSONB result
const resultJson = result[0]?.deduct_allowance_from_entitlements as {
updates: Record<
string,
{
balance: number;
entities: any;
adjustment: number;
deducted: number;
}
>;
remaining: number;
};
if (!resultJson) {
throw new InternalError({
message: "Failed to deduct from entitlements",
}); });
} }
handleThresholdReached({ const { updates, remaining } = resultJson;
org,
env, // Check if deduction was rejected due to limits
features: ctx.features, if (remaining > 0 && overageBehaviour === "reject") {
throw new InsufficientBalanceError({
message: `Insufficient balance to deduct ${toDeduct}. Remaining: ${remaining}`,
});
}
ctx.logger.info(
`Deducted ${toDeduct - remaining} from feature ${feature.id}. Updated ${
Object.keys(updates).length
} entitlements. Remaining: ${remaining}`,
);
// Bill on Stripe for each updated entitlement
const cusPrices = cusProductsToCusPrices({
cusProducts: fullCus.customer_products,
});
for (const cusEntId of Object.keys(updates)) {
const update = updates[cusEntId];
const cusEnt = cusEnts.find((ce) => ce.id === cusEntId);
if (!cusEnt) continue;
// Calculate original negative balance
const originalGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: cusEnt.balance!,
entities: cusEnt.entities!,
});
// Calculate new negative balance from updates
const newGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: update.balance,
entities: update.entities,
});
await adjustAllowance({
db, db,
feature, env,
cusEnts: originalCusEnts, org,
newCusEnts: cusEnts, cusPrices: cusPrices as any,
fullCus: customer, customer: fullCus,
affectedFeature: feature,
cusEnt: cusEnt as any,
originalBalance: originalGrpBalance,
newBalance: newGrpBalance,
logger: ctx.logger, logger: ctx.logger,
}); });
// Insert event into database updateCusEntInFullCus({
return customer; fullCus,
cusEntId,
update,
});
} }
}
return fullCus;
}; };
export const runDeductionTx = async (params: DeductionTxParams) => { export const runDeductionTx = async (
params: DeductionTxParams,
): Promise<{
fullCus: FullCustomer | undefined;
event: Event | undefined;
}> => {
const ctx = params.ctx; const ctx = params.ctx;
const { db, org, env, logger } = ctx; const { db, org, env } = ctx;
let fullCus: FullCustomer | undefined;
let event: Event | undefined;
await db.transaction( await db.transaction(
async (tx) => { async (tx) => {
// Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests // Pass tx as the db connection
// Include entity_id in lock key so different entities can update concurrently const txParams = {
const lockKeyStr = `${params.customerId}_${org.id}_${env}${params.entityId ? `_${params.entityId}` : ""}`; ...params,
ctx: {
...ctx,
db: tx as unknown as typeof db,
},
};
const hash = fullCus = await deductFromCusEnts(txParams);
lockKeyStr.split("").reduce((acc, char) => {
return (acc << 5) - acc + char.charCodeAt(0);
}, 0) | 0; // Convert to 32-bit integer
logger.info(`Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`); if (!fullCus) return;
// Time this
const start = Date.now();
await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`);
const elapsed = Date.now() - start;
logger.info(`Advisory lock acquired in ${elapsed}ms`);
const customer = await deductFromCusEnts(params);
if (!customer) return;
if (params.eventInfo) { if (params.eventInfo) {
const newEvent = await constructEvent({ const newEvent = await constructEvent({
ctx, ctx: txParams.ctx,
eventInfo: params.eventInfo, eventInfo: params.eventInfo,
fullCus: customer, fullCus,
}); });
await EventService.insert({ event = await EventService.insert({
db: tx as unknown as DrizzleCli, db: tx as unknown as DrizzleCli,
event: newEvent, event: newEvent,
}); });
} }
// return await updateUsage({
// db: tx as unknown as DrizzleCli,
// customerId,
// features,
// value,
// properties,
// org,
// env,
// setUsage: set_usage,
// logger,
// entityId,
// allFeatures,
// });
}, },
{ {
isolationLevel: "read committed", isolationLevel: "read committed",
@@ -208,4 +272,107 @@ export const runDeductionTx = async (params: DeductionTxParams) => {
org, org,
env, env,
}); });
return {
fullCus,
event,
};
}; };
// const customer = await CusService.getFull({
// db,
// idOrInternalId: customerId,
// orgId: org.id,
// env,
// inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
// entityId,
// withSubs: true,
// });
// const cusEnts = cusProductsToCusEnts({
// cusProducts: customer.customer_products,
// featureIds: deductions.map((d) => d.feature.id),
// reverseOrder: org.config?.reverse_deduction_order,
// });
// const cusPrices = cusProductsToCusPrices({
// cusProducts: customer.customer_products,
// });
// if (cusEnts.length === 0) return;
// validateDeductionPossible({ cusEnts, deductions, entityId });
// const originalCusEnts = structuredClone(cusEnts);
// for (const obj of deductions) {
// const { feature, deduction } = obj;
// let toDeduct = deduction;
// for (const cusEnt of cusEnts) {
// if (cusEnt.entitlement.internal_feature_id !== feature.internal_id) {
// continue;
// }
// toDeduct = await deductFromApiCusRollovers({
// toDeduct,
// cusEnt,
// deductParams: {
// db,
// feature,
// env,
// entity: customer.entity ? customer.entity : undefined,
// },
// });
// if (toDeduct === 0) continue;
// toDeduct = await deductAllowanceFromCusEnt({
// toDeduct,
// cusEnt,
// deductParams: {
// db,
// feature,
// env,
// org,
// cusPrices: cusPrices as any[],
// customer,
// entity: customer.entity,
// },
// featureDeductions: deductions,
// willDeductCredits: true,
// setZeroAdjustment: true,
// });
// }
// if (toDeduct !== 0) {
// await deductFromUsageBasedCusEnt({
// toDeduct,
// cusEnts,
// deductParams: {
// db,
// feature,
// env,
// org,
// cusPrices: cusPrices as any[],
// customer,
// entity: customer.entity,
// },
// setZeroAdjustment: true,
// });
// }
// handleThresholdReached({
// org,
// env,
// features: ctx.features,
// db,
// feature,
// cusEnts: originalCusEnts,
// newCusEnts: cusEnts,
// fullCus: customer,
// logger: ctx.logger,
// });
// // Insert event into database
// return customer;
// }

View File

@@ -57,18 +57,11 @@ export const getCusEntMasterBalance = ({
// Get unused count // Get unused count
const unusedCount =
entities &&
entities.filter(
(entity) =>
entity.internal_feature_id == feature.internal_id && entity.deleted,
).length;
return { return {
balance: cusEnt.balance, balance: cusEnt.balance,
adjustment: cusEnt.adjustment, adjustment: cusEnt.adjustment,
count: 1, count: 1,
unused: unusedCount, unused: cusEnt.replaceables?.length || 0,
}; };
}; };
@@ -115,9 +108,9 @@ export const getRelatedCusPrice = (
) => { ) => {
return cusPrices.find((cusPrice) => { return cusPrices.find((cusPrice) => {
const productMatch = const productMatch =
cusPrice.customer_product_id == cusEnt.customer_product_id; cusPrice.customer_product_id === cusEnt.customer_product_id;
const entMatch = cusPrice.price.entitlement_id == cusEnt.entitlement.id; const entMatch = cusPrice.price.entitlement_id === cusEnt.entitlement.id;
return productMatch && entMatch; return productMatch && entMatch;
}); });

View File

@@ -73,12 +73,15 @@ export const featureToCreditSystem = ({
export const getCreditCost = ({ export const getCreditCost = ({
featureId, featureId,
creditSystem, creditSystem,
amount, amount = 1,
}: { }: {
featureId: string; featureId: string;
creditSystem: Feature; creditSystem: Feature;
amount: number; amount?: number;
}) => { }) => {
if (creditSystem.type !== FeatureType.CreditSystem) {
return amount;
}
const schema: CreditSchemaItem[] = creditSystem.config.schema; const schema: CreditSchemaItem[] = creditSystem.config.schema;
for (const schemaItem of schema) { for (const schemaItem of schema) {

View File

@@ -75,12 +75,14 @@ export const constructMeteredFeature = ({
orgId, orgId,
env, env,
usageType, usageType,
eventNames = [],
}: { }: {
featureId: string; featureId: string;
name?: string; name?: string;
orgId: string; orgId: string;
env: AppEnv; env: AppEnv;
usageType: FeatureUsageType; usageType: FeatureUsageType;
eventNames?: string[];
}) => { }) => {
const newFeature: Feature = { const newFeature: Feature = {
internal_id: generateId("fe"), internal_id: generateId("fe"),
@@ -106,7 +108,7 @@ export const constructMeteredFeature = ({
usage_type: usageType, usage_type: usageType,
}, },
archived: false, archived: false,
event_names: [], event_names: eventNames,
}; };
return newFeature; return newFeature;

38
server/src/test.ts Normal file
View File

@@ -0,0 +1,38 @@
import "dotenv/config";
import { AutumnInt } from "./external/autumn/autumnCli.js";
const main = async () => {
const autumn = new AutumnInt({ secretKey: process.env.JDEV! });
const concurrency = 1;
const promises = [];
for (let i = 0; i < concurrency; i++) {
const simulateTrack = async () => {
const start = Date.now();
const response = await autumn.track({
customer_id: "john",
feature_id: "credits",
value: 350,
entity_id: "entity_2",
});
console.log(response);
const end = Date.now();
console.log(`Track ${i} took ${end - start}ms`);
return {
latency: end - start,
};
};
promises.push(simulateTrack());
}
const results = await Promise.all(promises);
const latencies = results.map((r) => r.latency);
const p99Latency = latencies.sort((a, b) => a - b)[
Math.floor(latencies.length * 0.99)
];
console.log(`P99 latency: ${p99Latency}ms`);
};
main()
.catch(console.error)
.then(() => process.exit(0));

View File

@@ -96,7 +96,7 @@ export const adjustAllowance = async ({
!cusProduct || !cusProduct ||
!cusPrice || !cusPrice ||
billingType !== BillingType.InArrearProrated || billingType !== BillingType.InArrearProrated ||
originalBalance == newBalance originalBalance === newBalance
) { ) {
return { newReplaceables: [], invoice: null, deletedReplaceables: null }; return { newReplaceables: [], invoice: null, deletedReplaceables: null };
} }

View File

@@ -3,6 +3,8 @@ import {
type AppEnv, type AppEnv,
CusProductStatus, CusProductStatus,
type Customer, type Customer,
customerEntitlements,
customers,
ErrCode, ErrCode,
type Feature, type Feature,
FeatureType, FeatureType,
@@ -521,21 +523,31 @@ export const runUpdateUsageTask = async ({
const cusEnts = await db.transaction( const cusEnts = await db.transaction(
async (tx) => { async (tx) => {
// Lock ALL customer entitlements for this customer using JOIN
await tx.execute(sql`
SELECT ce.*
FROM ${customerEntitlements} ce
INNER JOIN ${customers} c ON ce.internal_customer_id = c.internal_id
WHERE c.id = ${customerId}
AND c.org_id = ${org.id}
AND c.env = ${env}
FOR UPDATE OF ce
`);
// Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests // Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests
// Include entity_id in lock key so different entities can update concurrently // Include entity_id in lock key so different entities can update concurrently
const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`; // const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`;
const hash = // const hash =
lockKeyStr.split("").reduce((acc, char) => { // lockKeyStr.split("").reduce((acc, char) => {
return (acc << 5) - acc + char.charCodeAt(0); // return (acc << 5) - acc + char.charCodeAt(0);
}, 0) | 0; // Convert to 32-bit integer // }, 0) | 0; // Convert to 32-bit integer
console.log( // console.log(
` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`, // ` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`,
); // );
await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`); // await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`);
console.log( // console.log(
` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`, // ` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`,
); // );
return await updateUsage({ return await updateUsage({
db: tx as unknown as DrizzleCli, db: tx as unknown as DrizzleCli,

View File

@@ -1,79 +0,0 @@
import "dotenv/config";
import Stripe from "stripe";
const main = async () => {
const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || "");
const result = await stripe.webhookEndpoints.create({
url: "https://express.dev.useautumn.com/webhooks/connect/sandbox",
enabled_events: [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.discount.deleted",
"invoice.paid",
"invoice.upcoming",
"invoice.created",
"invoice.finalized",
"invoice.updated",
"subscription_schedule.canceled",
"subscription_schedule.updated",
],
connect: true,
});
console.log(result);
// const account = await stripe.v2.core.accounts.create({
// contact_email: "johnyeo10@gmail.com",
// display_name: "John Yeo",
// dashboard: "full",
// identity: {
// country: "us",
// },
// configuration: {
// merchant: {},
// },
// defaults: {
// responsibilities: {
// losses_collector: "stripe",
// fees_collector: "stripe",
// },
// },
// });
// console.log(account);
// console.log(result);
// const result = await stripe.v2.core.accounts.create({
// contact_email: "johnyeo10@gmail.com",
// display_name: "John Yeo",
// dashboard: "full",
// identity: {
// country: "us",
// },
// configuration: {
// merchant: {},
// },
// defaults: {
// responsibilities: {
// losses_collector: "stripe",
// fees_collector: "stripe",
// },
// },
// });
// console.log(result);
// const accountLink = await stripe.accountLinks.create({
// account: "acct_1SIqs0RAB2jVVcNG",
// refresh_url: "https://useautumn.com/refresh",
// return_url: "https://useautumn.com/return",
// type: "account_onboarding",
// });
// console.log(accountLink);
};
main()
.catch(console.error)
.then(() => process.exit(0));

View File

@@ -49,18 +49,51 @@ const balance = customer.features[TestFeature.Messages].balance;
const used = customer.features[TestFeature.Messages].used; const used = customer.features[TestFeature.Messages].used;
``` ```
### Expect Error ### Expect Error (Use This Instead of try-catch!)
**Always use `expectAutumnError` instead of manual try-catch blocks:**
```typescript ```typescript
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
// ✅ GOOD - Use expectAutumnError
await expectAutumnError({ await expectAutumnError({
errCode: ErrCode.CustomerNotFound, errCode: ErrCode.CustomerNotFound,
func: async () => { func: async () => {
await autumn.customers.get("invalid-id"); await autumn.customers.get("invalid-id");
}, },
}); });
// ✅ GOOD - Test for duplicate idempotency key
await expectAutumnError({
errCode: ErrCode.DuplicateIdempotencyKey,
func: async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
idempotency_key: "same-key",
});
},
});
// ❌ BAD - Don't use try-catch
let errorThrown = false;
try {
await autumn.customers.get("invalid-id");
} catch (error) {
errorThrown = true;
}
expect(errorThrown).toBe(true);
``` ```
**Common Error Codes:**
- `ErrCode.CustomerNotFound`
- `ErrCode.ProductNotFound`
- `ErrCode.FeatureNotFound`
- `ErrCode.InsufficientBalance`
- `ErrCode.DuplicateIdempotencyKey`
- `ErrCode.InvalidRequest`
## Public Key Restrictions ## Public Key Restrictions
Public keys can only access: Public keys can only access:
@@ -80,6 +113,38 @@ Public keys CANNOT:
- `test` - Individual test cases - `test` - Individual test cases
- Use descriptive test names with `chalk.yellowBright()` - Use descriptive test names with `chalk.yellowBright()`
## Customer Initialization
### Payment Methods
**IMPORTANT:** If your product has ANY price (overage, per-seat, usage-based, etc.), you MUST attach a payment method:
```typescript
// ✅ GOOD - Product with prices requires payment method
await initCustomerV3({
ctx,
customerId,
attachPm: "success", // Required for any paid features
withTestClock: false,
});
// ❌ BAD - Product with prices but no payment method
await initCustomerV3({
ctx,
customerId,
withTestClock: false, // Missing attachPm: "success"
});
```
Use `attachPm: "success"` when:
- Product has overage pricing (arrear items)
- Product has per-seat pricing
- Product has usage-based billing
- Any feature can trigger billing
Omit `attachPm` only for:
- Completely free products (no prices at all)
- Tests that don't require billing
## Imports ## Imports
```typescript ```typescript

View File

@@ -0,0 +1,536 @@
# Guide: Writing /track Endpoint Tests
## What is /track?
The `/track` endpoint records usage for metered features and deducts from customer balances.
**Parameters:**
- `customer_id` (required) - The customer to track usage for
- `feature_id` OR `event_name` (required) - The feature or event to track
- `value` (optional) - The amount to track (defaults to 1)
- `entity_id` (optional) - For entity-scoped features
**Behavior:**
- Deducts from customer balances
- Returns synchronously (no need for timeouts)
- Supports credit systems with automatic fallback
- Handles concurrent requests with SQL-level atomicity
## Step-by-Step: Writing a /track Test
### Step 1: Define What You're Testing
Identify the specific scenario:
- Basic metered feature deduction
- Credit system deduction
- Event-based tracking (multiple features from one event)
- Deduction order (feature → credit system)
- Concurrent track requests
- Balance capping (stop at 0 vs allow negative)
- Entity-scoped tracking
### Step 2: Construct Features & Products
#### Feature Types
**Basic Metered Features**:
```typescript
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
```
**Event-Based Features** (multiple features triggered by one event):
```typescript
// Both action1 and action2 listen to "action-event"
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 200,
});
const action2Feature = constructFeatureItem({
featureId: TestFeature.Action2,
includedUsage: 150,
});
```
**Credit Systems** (fallback pool for actions):
```typescript
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 100,
}) as LimitedItem;
// Action1 consumes from Credits with credit_cost = 0.2
// Action2 consumes from Credits with credit_cost = 0.6
```
#### Combine into Products
```typescript
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature, creditsFeature],
});
```
### Step 3: Initialize Test Environment
**Always use this exact order in `beforeAll`:**
```typescript
import { Decimal } from "decimal.js";
const testCase = "track-basic1";
const customerId = "track-basic1";
beforeAll(async () => {
// 1. Create customer
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// 2. Create products
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
// 3. Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
```
### Step 4: Write Test Cases
**IMPORTANT: Use Decimal for balance calculations to avoid floating point errors**
```typescript
test("should deduct exact value provided", async () => {
const initialBalance = 100;
const deductValue = 23.47;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
// Use Decimal to avoid floating point errors
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
expect(balance).toBe(expectedBalance);
expect(usage).toBe(deductValue);
});
```
## Common Scenarios
### 1. Basic Track (No Value)
```typescript
test("should deduct 1 when no value provided", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
// No value = defaults to 1
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(99);
expect(customer.features[TestFeature.Messages].usage).toBe(1);
});
```
### 2. Track with Value
```typescript
test("should deduct exact value", async () => {
const initialBalance = 100;
const deductValue = 37.89; // Use decimals for robustness
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
});
```
### 3. Event-Based Tracking
```typescript
test("should deduct from multiple features using event_name", async () => {
const deductValue = 45.67;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event", // Triggers action1 AND action2
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both features deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
new Decimal(200).sub(deductValue).toNumber()
);
expect(customer.features[TestFeature.Action2].balance).toBe(
new Decimal(150).sub(deductValue).toNumber()
);
});
```
### 4. Credit Systems
**Direct Credit Tracking:**
```typescript
test("should deduct from credits directly", async () => {
const deductValue = 27.35;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(100).sub(deductValue).toNumber()
);
});
```
**Track Action (Uses Credits with Multiplier):**
```typescript
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
test("should deduct from credits with credit_cost multiplier", async () => {
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
const action1Value = 50.25;
const expectedCreditCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: action1Value,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: action1Value,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(200).sub(expectedCreditCost).toNumber()
);
});
```
### 5. Deduction Order (Feature First, Then Credits)
```typescript
test("should deduct from action1 first, then credits", async () => {
// Product has: action1 (100 units) + credits (200 units)
// First track: only affects action1
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 40.5,
});
let customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(59.5);
expect(customer.features[TestFeature.Credits].balance).toBe(200); // Untouched
// Second track: finishes action1, dips into credits
const deductValue = 80;
const remainingAction1 = 59.5;
const overflowAmount = deductValue - remainingAction1;
const creditCostForOverflow = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: overflowAmount,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: deductValue,
});
customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(0); // Depleted
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(200).sub(creditCostForOverflow).toNumber()
);
});
```
### 6. Concurrent Requests
```typescript
test("should handle concurrent requests correctly", async () => {
const initialBalance = 100;
// Send 5 concurrent requests, each trying to deduct 10
const promises = [
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
];
await Promise.all(promises);
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(initialBalance).sub(50).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
expect(customer.features[TestFeature.Messages].usage).toBe(50);
});
```
### 7. Balance Capping
```typescript
test("should cap balance at 0 with default behavior", async () => {
// Initial balance: 5
// Try to deduct: 50 (more than available)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(0); // Capped
expect(customer.features[TestFeature.Messages].usage).toBe(5); // Only deducted what was available
});
```
## Multiple Credit System Pairs
```typescript
test("should deduct from two credit system pairs simultaneously", async () => {
// Product has:
// - action1 (80) + credits (150)
// - action3 (60) + credits2 (100)
const deductValue = 25.5;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event", // Triggers both action1 and action3
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both actions deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
new Decimal(80).sub(deductValue).toNumber()
);
expect(customer.features[TestFeature.Action3].balance).toBe(
new Decimal(60).sub(deductValue).toNumber()
);
// Credits untouched (actions had enough balance)
expect(customer.features[TestFeature.Credits].balance).toBe(150);
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
});
```
## Required Imports
```typescript
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.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";
```
## Test File Template
```typescript
import { Decimal } from "decimal.js";
const testCase = "track-X";
const customerId = "track-X";
const someFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [someFeature],
});
describe(`${chalk.yellowBright("track-X: description")}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balance", async () => {
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(100);
});
test("should deduct correctly", async () => {
const deductValue = 23.47; // Use random decimals
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(100).sub(deductValue).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
});
});
```
## Key Differences from /check
| Aspect | /check | /track |
|--------|--------|--------|
| **Purpose** | Validate access | Record usage |
| **Modifies Data** | No | Yes (deducts balance) |
| **Returns** | Allowed/balance info | Success/event details |
| **Synchronous** | Yes | Yes (no timeouts needed) |
| **Credit Systems** | Check action, shows credit balance | Deducts from action, falls back to credits |
| **Concurrency** | N/A | Handled with SQL atomicity |
## Best Practices
### ✅ DO
- Use `Decimal` for all balance calculations: `new Decimal(100).sub(23.47).toNumber()`
- Use random decimal values (23.47, 37.89, 50.25) for test robustness
- Test initial balance before tracking
- Test both `feature_id` and `event_name` approaches
- Import `getCreditCost` when testing credit systems
- Test deduction order (feature → credits)
- Verify both `balance` and `usage` fields
### ❌ DON'T
- Don't use raw arithmetic: `100 - 23.47` (floating point errors!)
- Don't use timeouts (track is synchronous)
- Don't test on Credits feature directly (test on actions)
- Don't assume balance order without sorting
- Don't forget to test concurrent scenarios
## Checklist
- [ ] Unique test case name (e.g., "track-basic1")
- [ ] Use chalk for describe block
- [ ] Use `Decimal` for balance calculations
- [ ] Random decimal values for `value` parameter
- [ ] Initialize in correct order: customer → products → attach
- [ ] Test initial balance first
- [ ] For credit systems: use `getCreditCost` helper
- [ ] Verify both `balance` and `usage` fields
- [ ] Test concurrent requests when relevant
- [ ] No setTimeout/timeouts (track is synchronous)
## Common Pitfalls
### ❌ Floating Point Error
```typescript
// BAD
expect(balance).toBe(100 - 23.47); // May fail due to floating point
// GOOD
expect(balance).toBe(new Decimal(100).sub(23.47).toNumber());
```
### ❌ Testing Credits Directly
```typescript
// BAD - Tests credit feature directly
await autumnV1.track({
feature_id: TestFeature.Credits,
value: 50,
});
// GOOD - Tests action that uses credits
await autumnV1.track({
feature_id: TestFeature.Action1,
value: 50,
});
// Then check both action1 and credits balances
```
### ❌ Forgetting Credit Cost Multiplier
```typescript
// BAD - Assumes 1:1 deduction
expect(credits.balance).toBe(100 - 50);
// GOOD - Calculates with credit_cost
const expectedCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature,
amount: 50,
});
expect(credits.balance).toBe(new Decimal(100).sub(expectedCost).toNumber());
```
## Advanced: Testing Deduction Order
When a product has both a metered feature AND a credit system:
1. **First**: Deducts from the metered feature
2. **Then**: When depleted, falls back to credit system
3. **Credit Cost**: Applied when using credit system (not 1:1)
```typescript
// Setup: action1 (100) + credits (200), credit_cost = 0.2
// Track 40 → only action1 affected
// action1: 60, credits: 200
// Track 80 → finishes action1 (60), then uses credits for remaining 20
// action1: 0, credits: 200 - (20 * 0.2) = 196
// Track 50 → only credits affected
// action1: 0, credits: 196 - (50 * 0.2) = 186
```

View File

@@ -176,6 +176,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
expect(rollover1.balance).to.equal(0); expect(rollover1.balance).to.equal(0);
expect(rollover2.balance).to.equal(350); expect(rollover2.balance).to.equal(350);
}); });
return;
it("should track and deduct from rollover + original balance", async () => { it("should track and deduct from rollover + original balance", async () => {
await autumn.track({ await autumn.track({

View File

@@ -147,7 +147,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
await resetAndGetCusEnt({ await resetAndGetCusEnt({
db, db,
customer, customer,
productGroup: free.group, productGroup: free.group!,
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
}); });
@@ -166,7 +166,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
await resetAndGetCusEnt({ await resetAndGetCusEnt({
db, db,
customer, customer,
productGroup: free.group, productGroup: free.group!,
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
}); });

View File

@@ -95,7 +95,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () =>
feature_id: TestFeature.Words, feature_id: TestFeature.Words,
value: usage, value: usage,
}); });
await timeout(4000);
const cus = await CusService.get({ const cus = await CusService.get({
db, db,

View File

@@ -0,0 +1,68 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "track-basic1";
describe(`${chalk.yellowBright("track-basic1: track with no value provided")}`, () => {
const customerId = "track-basic1";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balance of 100", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(100);
});
test("should deduct 1 when no value provided", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
expect(balance).toBe(99);
expect(usage).toBe(1);
});
});

View File

@@ -0,0 +1,71 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "track-basic2";
describe(`${chalk.yellowBright("track-basic2: track with value provided")}`, () => {
const customerId = "track-basic2";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balance of 100", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(100);
});
test("should deduct exact value provided", async () => {
const deductValue = 23.47;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
expect(balance).toBe(100 - deductValue);
expect(usage).toBe(deductValue);
});
});

View File

@@ -0,0 +1,71 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 150,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [action1Feature],
});
const testCase = "track-basic3";
describe(`${chalk.yellowBright("track-basic3: track with event_name instead of feature_id")}`, () => {
const customerId = "track-basic3";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balance of 150", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Action1].balance;
expect(balance).toBe(150);
});
test("should deduct from action1 using event_name", async () => {
const deductValue = 37.89;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event",
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Action1].balance;
const usage = customer.features[TestFeature.Action1].usage;
expect(balance).toBe(150 - deductValue);
expect(usage).toBe(deductValue);
});
});

View File

@@ -0,0 +1,85 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 200,
});
const action3Feature = constructFeatureItem({
featureId: TestFeature.Action3,
includedUsage: 150,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [action1Feature, action3Feature],
});
const testCase = "track-basic4";
describe(`${chalk.yellowBright("track-basic4: track with event_name deducts from multiple features")}`, () => {
const customerId = "track-basic4";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balances", async () => {
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(200);
expect(customer.features[TestFeature.Action3].balance).toBe(150);
});
test("should deduct from both action1 and action3 using event_name", async () => {
const deductValue = 45.67;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event",
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const action1Balance = customer.features[TestFeature.Action1].balance;
const action1Usage = customer.features[TestFeature.Action1].usage;
const action3Balance = customer.features[TestFeature.Action3].balance;
const action3Usage = customer.features[TestFeature.Action3].usage;
const expectedAction1Balance = new Decimal(200).sub(deductValue).toNumber();
const expectedAction3Balance = new Decimal(150).sub(deductValue).toNumber();
expect(action1Balance).toBe(expectedAction1Balance);
expect(action1Usage).toBe(deductValue);
expect(action3Balance).toBe(expectedAction3Balance);
expect(action3Usage).toBe(deductValue);
});
});

View File

@@ -0,0 +1,91 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const action2Feature = constructFeatureItem({
featureId: TestFeature.Action2,
includedUsage: 150,
});
const action3Feature = constructFeatureItem({
featureId: TestFeature.Action3,
includedUsage: 200,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [action1Feature, action2Feature, action3Feature],
});
const testCase = "track-basic5";
describe(`${chalk.yellowBright("track-basic5: track specific feature_id only affects that feature")}`, () => {
const customerId = "track-basic5";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balances for all features", async () => {
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(100);
expect(customer.features[TestFeature.Action2].balance).toBe(150);
expect(customer.features[TestFeature.Action3].balance).toBe(200);
});
test("should only deduct from action1 when tracking feature_id: action1", async () => {
const deductValue = 37.82;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// action1 should be deducted
const expectedAction1Balance = new Decimal(100).sub(deductValue).toNumber();
expect(customer.features[TestFeature.Action1].balance).toBe(
expectedAction1Balance,
);
expect(customer.features[TestFeature.Action1].usage).toBe(deductValue);
// action2 and action3 should remain unchanged
expect(customer.features[TestFeature.Action2].balance).toBe(150);
expect(customer.features[TestFeature.Action2].usage).toBe(0);
expect(customer.features[TestFeature.Action3].balance).toBe(200);
expect(customer.features[TestFeature.Action3].usage).toBe(0);
});
});

View File

@@ -0,0 +1,131 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "track-basic6";
describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents duplicate tracks")}`, () => {
const customerId = "track-basic6";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balance of 100", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(100);
});
test("should process first track with idempotency key", async () => {
const deductValue = 25.5;
const idempotencyKey = "test-idempotency-key-1";
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
idempotency_key: idempotencyKey,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
const expectedBalance = new Decimal(100).sub(deductValue).toNumber();
expect(balance).toBe(expectedBalance);
expect(usage).toBe(deductValue);
});
test("should reject second track with same idempotency key", async () => {
const deductValue = 30.75; // Different value
const idempotencyKey = "test-idempotency-key-1"; // Same key
// 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);
// Balance should remain unchanged
const customerAfter = await autumnV1.customers.get(customerId);
const balanceAfter = customerAfter.features[TestFeature.Messages].balance;
expect(balanceAfter).toBe(balanceBefore);
});
test("should process track with different idempotency key", async () => {
const deductValue = 15.25;
const idempotencyKey = "test-idempotency-key-2"; // Different key
const customerBefore = await autumnV1.customers.get(customerId);
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
idempotency_key: idempotencyKey,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const expectedBalance = new Decimal(balanceBefore!)
.sub(deductValue)
.toNumber();
expect(balance).toBe(expectedBalance);
});
});

View File

@@ -0,0 +1,128 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
unlimited: true,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "track-basic7";
describe(`${chalk.yellowBright("track-basic7: track with unlimited balance")}`, () => {
const customerId = "track-basic7";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have unlimited balance initially", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const unlimited = customer.features[TestFeature.Messages].unlimited;
expect(balance).toBe(0);
expect(unlimited).toBe(true);
});
test("should remain unlimited after tracking without value", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const unlimited = customer.features[TestFeature.Messages].unlimited;
const usage = customer.features[TestFeature.Messages].usage;
expect(balance).toBe(0);
expect(unlimited).toBe(true);
expect(usage).toBe(1);
});
test("should remain unlimited after tracking with small value", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const unlimited = customer.features[TestFeature.Messages].unlimited;
const usage = customer.features[TestFeature.Messages].usage;
expect(balance).toBe(0);
expect(unlimited).toBe(true);
expect(usage).toBe(11); // 1 from previous test + 10
});
test("should remain unlimited after tracking with large value", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1000000,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const unlimited = customer.features[TestFeature.Messages].unlimited;
const usage = customer.features[TestFeature.Messages].usage;
expect(balance).toBe(0);
expect(unlimited).toBe(true);
expect(usage).toBe(1000011); // 11 from previous tests + 1000000
});
test("should remain unlimited after multiple concurrent tracks", async () => {
const trackPromises = Array.from({ length: 10 }, (_, i) =>
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: i + 1,
}),
);
await Promise.all(trackPromises);
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const unlimited = customer.features[TestFeature.Messages].unlimited;
const usage = customer.features[TestFeature.Messages].usage;
// 1000011 from previous + sum(1..10) = 1000011 + 55
expect(balance).toBe(0);
expect(unlimited).toBe(true);
expect(usage).toBe(1000066);
});
});

View File

@@ -0,0 +1,136 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearItem,
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 { trackWasSuccessful } from "../trackTestUtils.js";
const testCase = "trackBasic8";
const prepaidCustomerId = `${testCase}_prepaid`;
const payPerUseCustomerId = `${testCase}_payperuse`;
// Prepaid feature: 5 included, no overage allowed
const prepaidItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 5,
});
// PayPerUse feature: 5 included, overage allowed at $0.01 per unit, usage_limit of 10
const payPerUseItem = constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 5,
price: 0.01,
billingUnits: 1,
usageLimit: 10,
});
const prepaidProduct = constructProduct({
id: "prepaid",
items: [prepaidItem],
type: "pro",
});
const payPerUseProduct = constructProduct({
id: "payperuse",
items: [payPerUseItem],
type: "pro",
});
describe(`${chalk.yellowBright(`${testCase}: Testing prepaid vs pay-per-use overage behavior`)}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
// Initialize both customers
await initCustomerV3({
ctx,
customerId: prepaidCustomerId,
withTestClock: false,
attachPm: "success",
});
await initCustomerV3({
ctx,
customerId: payPerUseCustomerId,
withTestClock: false,
attachPm: "success",
});
// Initialize products
await initProductsV0({
ctx,
products: [prepaidProduct, payPerUseProduct],
prefix: testCase,
});
// Attach prepaid product to prepaid customer
await autumnV1.attach({
customer_id: prepaidCustomerId,
product_id: prepaidProduct.id,
});
// Attach payPerUse product to payPerUse customer
await autumnV1.attach({
customer_id: payPerUseCustomerId,
product_id: payPerUseProduct.id,
});
});
test("should have initial balance of 5 for prepaid customer", async () => {
const customer = await autumnV1.customers.get(prepaidCustomerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(5);
});
test("should have initial balance of 5 for pay-per-use customer", async () => {
const customer = await autumnV1.customers.get(payPerUseCustomerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(5);
});
test("should reject tracking 7 units when prepaid balance is 5 (no overage)", async () => {
const res = await autumnV1.track({
customer_id: prepaidCustomerId,
feature_id: TestFeature.Messages,
value: 7,
overage_behaviour: "reject",
});
expect(trackWasSuccessful({ res })).toBe(false);
expect(res.code).toBe("insufficient_balance");
// Verify balance remains unchanged
const finalCustomer = await autumnV1.customers.get(prepaidCustomerId);
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
expect(finalBalance).toBe(5);
});
test("should allow tracking 7 units when PayPerUse balance is 5 (overage allowed)", async () => {
const res = await autumnV1.track({
customer_id: payPerUseCustomerId,
feature_id: TestFeature.Messages,
value: 7,
overage_behaviour: "reject",
});
expect(trackWasSuccessful({ res })).toBe(true);
// Verify balance went negative (overage)
const finalCustomer = await autumnV1.customers.get(payPerUseCustomerId);
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
const finalUsage = finalCustomer.features[TestFeature.Messages].usage;
expect(finalBalance).toBe(-2);
expect(finalUsage).toBe(7);
});
});

View File

@@ -0,0 +1,102 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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 { trackWasSuccessful } from "../trackTestUtils.js";
const testCase = "concurrentTrack1";
const customerId = testCase;
const free = constructProduct({
type: "free",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 5,
}),
],
});
describe(`${chalk.yellowBright(`concurrentTrack1: Testing track with concurrent requests and balance capping`)}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [free],
prefix: testCase,
});
// Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: free.id,
});
});
test("should have initial balance of 5", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(5);
});
test("should allow concurrent requests and cap balance at 0", async () => {
// Send 5 concurrent requests, each trying to deduct 10
// Only 5 should be deducted (initial balance), capping at 0
const promises = [
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
];
const results = await Promise.all(promises);
// With cap behavior, all requests pass (cap at 0 instead of rejecting)
const allFulfilled = results.every((r) => trackWasSuccessful({ res: r }));
expect(allFulfilled).toBe(true);
// Check final balance
const customer = await autumnV1.customers.get(customerId);
const finalBalance = customer.features[TestFeature.Messages].balance;
const finalUsage = customer.features[TestFeature.Messages].usage;
expect(finalBalance).toBe(0);
expect(finalUsage).toBe(5); // Only 5 was actually deducted (initial balance)
});
});

View File

@@ -0,0 +1,104 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, ProductItemFeatureType } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const testCase = "concurrentTrack2";
const customerId = testCase;
const pro = constructProduct({
type: "free",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 1,
featureType: ProductItemFeatureType.ContinuousUse,
}),
],
});
describe(`${chalk.yellowBright(`concurrentTrack2: Testing concurrent track, allocated feature`)}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
// Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
});
test("should have initial balance of 1", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Users].balance;
expect(balance).toBe(1);
});
test("should only allow one concurrent track with balance of 1", async () => {
const promises = [
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
];
await Promise.all(promises);
// console.log(results);
// return;
// const successCount = results.filter((r) => r.status === "fulfilled").length;
// const rejectedCount = results.filter((r) => r.status === "rejected").length;
// // Only 1 should succeed, 4 should be rejected due to insufficient balance
// expect(successCount).toBe(1);
// expect(rejectedCount).toBe(4);
// Check final balance
const customer = await autumnV1.customers.get(customerId);
const finalBalance = customer.features[TestFeature.Users].balance;
expect(finalBalance).toBe(-4);
});
});

View File

@@ -0,0 +1,119 @@
// import { beforeAll, describe, expect, test } from "bun:test";
// import { ApiVersion, ProductItemFeatureType } from "@autumn/shared";
// import chalk from "chalk";
// import { TestFeature } from "tests/setup/v2Features.js";
// import ctx from "tests/utils/testInitUtils/createTestContext.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";
// const testCase = "trackMisc3";
// const customerId = `${testCase}_cus1`;
// const userItem = constructFeatureItem({
// featureId: TestFeature.Users,
// includedUsage: 1,
// featureType: ProductItemFeatureType.ContinuousUse,
// });
// const pro = constructProduct({
// items: [userItem],
// type: "pro",
// });
// describe(`${chalk.yellowBright(`${testCase}: Testing track prepaid allocated feature with concurrent requests`)}`, () => {
// const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
// beforeAll(async () => {
// await initCustomerV3({
// ctx,
// customerId,
// withTestClock: false,
// });
// await initProductsV0({
// ctx,
// products: [pro],
// prefix: testCase,
// });
// // Attach product to customer
// await autumnV1.attach({
// customer_id: customerId,
// product_id: pro.id,
// });
// });
// test("should have initial balance of 1", async () => {
// const customer = await autumnV1.customers.get(customerId);
// const balance = customer.features[TestFeature.Users].balance;
// expect(balance).toBe(1);
// });
// test("should only allow one concurrent seat allocation with 1 included seat and create no duplicate invoices", async () => {
// const customer = await autumnV1.customers.get(customerId);
// const initialInvoices = await ctx.stripeCli.invoices.list({
// customer: customer.stripe_id as string,
// });
// const initialInvoiceCount = initialInvoices.data.length;
// // Try to allocate 5 different seats concurrently - only 1 should succeed (the included seat)
// // The other 4 should be rejected because we only have 1 included seat
// const promises = [
// autumnV1.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: 1,
// }),
// autumnV1.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: 1,
// }),
// autumnV1.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: 1,
// }),
// autumnV1.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: 1,
// }),
// autumnV1.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: 1,
// }),
// ];
// const results = await Promise.allSettled(promises);
// const successCount = results.filter((r) => r.status === "fulfilled").length;
// const rejectedCount = results.filter((r) => r.status === "rejected").length;
// // Only 1 should succeed (included seat), 4 should be rejected
// expect(successCount).toBe(1);
// expect(rejectedCount).toBe(4);
// // Check final balance
// const finalCustomer = await autumnV1.customers.get(customerId);
// const finalBalance = finalCustomer.features[TestFeature.Users].balance;
// expect(finalBalance).toBe(0);
// // Verify no duplicate invoices were created
// // Since we only allocated the 1 included seat, no overage charges should occur
// const finalInvoices = await ctx.stripeCli.invoices.list({
// customer: customer.stripe_id as string,
// });
// const finalInvoiceCount = finalInvoices.data.length;
// const newInvoicesCreated = finalInvoiceCount - initialInvoiceCount;
// expect(newInvoicesCreated).toBe(0);
// });
// });

View File

@@ -0,0 +1,137 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructArrearItem } 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 { trackWasSuccessful } from "../trackTestUtils.js";
const testCase = "concurrentTrack4";
const customerId = testCase;
const messageItem = constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 5,
price: 0.1,
billingUnits: 1,
usageLimit: 10,
});
const pro = constructProduct({
id: "pro",
items: [messageItem],
type: "pro",
});
describe(`${chalk.yellowBright(`${testCase}: Testing usage_limits with pay_per_use feature and concurrent requests`)}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
// Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
});
test("should have initial balance of 5 with usage_limit of 10", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usageLimit = customer.features[TestFeature.Messages].usage_limit;
expect(balance).toBe(5);
expect(usageLimit).toBe(10);
});
test("should enforce usage_limit with concurrent requests", async () => {
console.log(
"🚀 Starting 5 concurrent track calls (3 units each) at exact same time...",
);
// Try to use 3 units concurrently - with usage_limit of 10, only 3 requests can succeed (3x3=9 <= 10)
const promises = [
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
overage_behaviour: "reject",
}),
];
const results = await Promise.all(promises);
console.log(results);
const successCount = results.filter((r) =>
trackWasSuccessful({ res: r }),
).length;
const rejectedCount = results.filter(
(r) => !trackWasSuccessful({ res: r }),
).length;
expect(successCount).toBe(3);
expect(rejectedCount).toBe(2);
// Wait for any async processing to complete
console.log(`⏳ Waiting 3s for all updates to persist...`);
await new Promise((resolve) => setTimeout(resolve, 3000));
const customer = await autumnV1.customers.get(customerId);
console.log(`📦 Final state after all requests:`);
console.log(
`- Balance: ${customer.features[TestFeature.Messages]?.balance} (expected: -4)`,
);
console.log(
`- Usage: ${customer.features[TestFeature.Messages]?.usage} (expected: 9)`,
);
console.log(
`- Usage limit: ${customer.features[TestFeature.Messages]?.usage_limit} (expected: 10)`,
);
expect(customer.features[TestFeature.Messages]?.balance).toBe(-4);
expect(customer.features[TestFeature.Messages]?.usage).toBe(9);
expect(customer.features[TestFeature.Messages]?.usage_limit).toBe(10);
});
});

View File

@@ -0,0 +1,185 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, ProductItemFeatureType } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearItem,
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 { trackWasSuccessful } from "../trackTestUtils.js";
const testCase = "concurrentTrack5";
const customerId = testCase;
const seatItem = constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 5,
featureType: ProductItemFeatureType.ContinuousUse,
});
const perSeatMessagesItem = constructArrearItem({
featureId: TestFeature.Messages,
entityFeatureId: TestFeature.Users,
price: 0.01,
includedUsage: 500,
usageLimit: 600,
});
const pro = constructProduct({
id: "pro",
items: [seatItem, perSeatMessagesItem],
type: "pro",
});
describe(`${chalk.yellowBright(`${testCase}: Testing per-entity track with concurrent requests`)}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
// Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
});
test("should create 5 seats each with 500 messages", async () => {
const customer = await autumnV1.customers.get(customerId);
const seatBalance = customer.features[TestFeature.Users].balance;
expect(seatBalance).toBe(5);
// Create 5 entities (seats)
const entities = [
{ id: "seat1", name: "Seat 1" },
{ id: "seat2", name: "Seat 2" },
{ id: "seat3", name: "Seat 3" },
{ id: "seat4", name: "Seat 4" },
{ id: "seat5", name: "Seat 5" },
];
for (const entity of entities) {
await autumnV1.entities.create(customerId, {
id: entity.id,
name: entity.name,
feature_id: TestFeature.Users,
});
}
// Verify each seat has 500 messages
const updatedEntity = await autumnV1.entities.get(
customerId,
entities[0].id,
);
expect(updatedEntity.features[TestFeature.Messages].balance).toBe(500);
});
test("should enforce usage_limit of 600 per seat with concurrent 200-unit requests", async () => {
const entityId = "seat1";
// Verify seat1 has 500 included messages with 600 usage_limit
const entityRes = await autumnV1.entities.get(customerId, entityId);
expect(entityRes.features[TestFeature.Messages].balance).toBe(500);
console.log(
"🚀 Starting 5 concurrent track calls (200 units each) for seat1...",
);
console.log(
` Initial state: balance=${entityRes.features[TestFeature.Messages].balance}, usage_limit=${entityRes.features[TestFeature.Messages].usage_limit}`,
);
// Try 5 concurrent 200-unit sends to seat1
// With usage_limit of 600, only 3 should succeed (3×200=600 <= 600)
const promises = [
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
overage_behaviour: "reject",
}),
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
overage_behaviour: "reject",
}),
];
const results = await Promise.all(promises);
const successCount = results.filter((r) =>
trackWasSuccessful({ res: r }),
).length;
const rejectedCount = results.filter(
(r) => !trackWasSuccessful({ res: r }),
).length;
console.log(
`\n📈 Summary: ${successCount} HTTP 200 responses, ${rejectedCount} HTTP errors`,
);
expect(successCount).toBe(3);
expect(rejectedCount).toBe(2);
// Get final state
const finalEntityRes = await autumnV1.entities.get(customerId, entityId);
console.log(`\n📦 Final state for ${entityId}:`);
console.log(
`- Balance: ${finalEntityRes.features[TestFeature.Messages].balance} (expected: -100)`,
);
console.log(
`- Usage: ${finalEntityRes.features[TestFeature.Messages].usage} (expected: 600)`,
);
console.log(
`- Usage limit: ${finalEntityRes.features[TestFeature.Messages].usage_limit} (expected: 600)`,
);
expect(finalEntityRes.features[TestFeature.Messages].balance).toBe(-100);
expect(finalEntityRes.features[TestFeature.Messages].usage).toBe(600);
// Verify other seats remain untouched at 500
for (const seatId of ["seat2", "seat3", "seat4", "seat5"]) {
const otherSeatRes = await autumnV1.entities.get(customerId, seatId);
expect(otherSeatRes.features[TestFeature.Messages].balance).toBe(500);
}
});
});

View File

@@ -0,0 +1,71 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 100,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [creditsFeature],
});
const testCase = "track-credit-system1";
describe(`${chalk.yellowBright("track-credit-system1: track credits directly")}`, () => {
const customerId = "track-credit-system1";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balance of 100 credits", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Credits].balance;
expect(balance).toBe(100);
});
test("should deduct from credits directly", async () => {
const deductValue = 27.35;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Credits].balance;
const usage = customer.features[TestFeature.Credits].usage;
expect(balance).toBe(100 - deductValue);
expect(usage).toBe(deductValue);
});
});

View File

@@ -0,0 +1,105 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.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";
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 200,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [creditsFeature],
});
const testCase = "track-credit-system2";
describe(`${chalk.yellowBright("track-credit-system2: track metered features using credit system")}`, () => {
const customerId = "track-credit-system2";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
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 have initial balance of 200 credits", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Credits].balance;
expect(balance).toBe(200);
});
test("should deduct from credits for action1 with credit_cost multiplier", async () => {
const action1Value = 50.25;
const expectedCreditCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: action1Value,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: action1Value,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Credits].balance;
const usage = customer.features[TestFeature.Credits].usage;
expect(balance).toBe(200 - expectedCreditCost);
expect(usage).toBe(expectedCreditCost);
});
test("should deduct from credits for action2 with different credit_cost", async () => {
// Get current balance after action1
const customerBefore = await autumnV1.customers.get(customerId);
const balanceBefore = customerBefore.features[TestFeature.Credits].balance!;
const action2Value = 33.67;
const expectedCreditCost = getCreditCost({
featureId: TestFeature.Action2,
creditSystem: creditFeature!,
amount: action2Value,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action2,
value: action2Value,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Credits].balance;
expect(balance).toBe(
new Decimal(balanceBefore).minus(expectedCreditCost).toNumber(),
);
});
});

View File

@@ -0,0 +1,146 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.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";
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 100,
}) as LimitedItem;
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 200,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [action1Feature, creditsFeature],
});
const testCase = "track-credit-system3";
describe(`${chalk.yellowBright("track-credit-system3: test deduction order - action1 first, then credits")}`, () => {
const customerId = "track-credit-system3";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
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 have initial balances", async () => {
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(100);
expect(customer.features[TestFeature.Credits].balance).toBe(200);
});
test("should deduct from action1 first (not credits)", async () => {
const deductValue = 40.5;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Action1 should be deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
100 - deductValue,
);
expect(customer.features[TestFeature.Action1].usage).toBe(deductValue);
// Credits should be untouched
expect(customer.features[TestFeature.Credits].balance).toBe(200);
expect(customer.features[TestFeature.Credits].usage).toBe(0);
});
test("should finish action1 balance and dip into credits", async () => {
// Current: action1 = 59.5, credits = 200
// Deduct 80 -> should take 59.5 from action1, then 20.5 from credits (with credit_cost)
const deductValue = 80;
const remainingAction1 = 59.5;
const overflowAmount = deductValue - remainingAction1;
const creditCostForOverflow = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: overflowAmount,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Action1 should be fully depleted
expect(customer.features[TestFeature.Action1].balance).toBe(0);
expect(customer.features[TestFeature.Action1].usage).toBe(100);
// Credits should be deducted by credit_cost * overflow
expect(customer.features[TestFeature.Credits].balance).toBe(
200 - creditCostForOverflow,
);
expect(customer.features[TestFeature.Credits].usage).toBe(
creditCostForOverflow,
);
});
test("should deduct only from credits now that action1 is depleted", async () => {
const customerBefore = await autumnV1.customers.get(customerId);
const creditsBefore = customerBefore.features[TestFeature.Credits].balance;
const deductValue = 50.75;
const creditCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: deductValue,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Action1 should still be 0
expect(customer.features[TestFeature.Action1].balance).toBe(0);
// Credits should be deducted
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(creditsBefore!).minus(creditCost).toNumber(),
);
});
});

View File

@@ -0,0 +1,207 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.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";
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 80,
}) as LimitedItem;
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 150,
}) as LimitedItem;
const action3Feature = constructFeatureItem({
featureId: TestFeature.Action3,
includedUsage: 60,
}) as LimitedItem;
const credits2Feature = constructFeatureItem({
featureId: TestFeature.Credits2,
includedUsage: 100,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [action1Feature, creditsFeature, action3Feature, credits2Feature],
});
const testCase = "track-credit-system4";
describe(`${chalk.yellowBright("track-credit-system4: test deduction with two credit system pairs")}`, () => {
const customerId = "track-credit-system4";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
const credit2Feature = ctx.features.find(
(f) => f.id === TestFeature.Credits2,
);
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 have initial balances for all features", async () => {
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(80);
expect(customer.features[TestFeature.Credits].balance).toBe(150);
expect(customer.features[TestFeature.Action3].balance).toBe(60);
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
});
test("should deduct from both action1 and action3 using event_name", async () => {
const deductValue = 25.5;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event",
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both actions should be deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
new Decimal(80).sub(deductValue).toNumber(),
);
expect(customer.features[TestFeature.Action1].usage).toBe(deductValue);
expect(customer.features[TestFeature.Action3].balance).toBe(
new Decimal(60).sub(deductValue).toNumber(),
);
expect(customer.features[TestFeature.Action3].usage).toBe(deductValue);
// Credits untouched (actions had enough balance)
expect(customer.features[TestFeature.Credits].balance).toBe(150);
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
});
test("should finish action1 and action3, then dip into both credit systems", async () => {
// Get current state after previous test
const customerBefore = await autumnV1.customers.get(customerId);
const remainingAction1 =
customerBefore.features[TestFeature.Action1].balance!;
const remainingAction3 =
customerBefore.features[TestFeature.Action3].balance!;
const creditsBefore = customerBefore.features[TestFeature.Credits].balance!;
const credits2Before =
customerBefore.features[TestFeature.Credits2].balance!;
// Deduct 70 -> should finish both actions, then use credits
const deductValue = 70;
const overflowAction1 = deductValue - remainingAction1;
const overflowAction3 = deductValue - remainingAction3;
const creditCostAction1 = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: overflowAction1,
});
const creditCostAction3 = getCreditCost({
featureId: TestFeature.Action3,
creditSystem: credit2Feature!,
amount: overflowAction3,
});
await autumnV1.track({
customer_id: customerId,
event_name: "action-event",
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both actions should be fully depleted
expect(customer.features[TestFeature.Action1].balance).toBe(0);
expect(customer.features[TestFeature.Action1].usage).toBe(80);
expect(customer.features[TestFeature.Action3].balance).toBe(0);
expect(customer.features[TestFeature.Action3].usage).toBe(60);
// Both credit systems should be deducted
const expectedCredits = new Decimal(creditsBefore)
.sub(creditCostAction1)
.toNumber();
expect(customer.features[TestFeature.Credits].balance).toBe(
expectedCredits,
);
const expectedCredits2 = new Decimal(credits2Before)
.sub(creditCostAction3)
.toNumber();
expect(customer.features[TestFeature.Credits2].balance).toBe(
expectedCredits2,
);
});
test("should deduct only from credit systems after actions depleted", async () => {
const customerBefore = await autumnV1.customers.get(customerId);
const creditsBefore = customerBefore.features[TestFeature.Credits].balance;
const credits2Before =
customerBefore.features[TestFeature.Credits2].balance;
const deductValue = 40.25;
const creditCostAction1 = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: deductValue,
});
const creditCostAction3 = getCreditCost({
featureId: TestFeature.Action3,
creditSystem: credit2Feature!,
amount: deductValue,
});
await autumnV1.track({
customer_id: customerId,
event_name: "action-event",
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Actions should still be 0
expect(customer.features[TestFeature.Action1].balance).toBe(0);
expect(customer.features[TestFeature.Action3].balance).toBe(0);
// Credits has enough balance
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(creditsBefore!).sub(creditCostAction1).toNumber(),
);
// Credits2 doesn't have enough balance, so it caps at 0 (no usage_allowed)
const expectedCredits2 = new Decimal(credits2Before!)
.sub(creditCostAction3)
.toNumber();
expect(customer.features[TestFeature.Credits2].balance).toBe(
Math.max(0, expectedCredits2),
);
});
});

View File

@@ -0,0 +1,80 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.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";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "track-legacy1";
describe(`${chalk.yellowBright("track-legacy1: test legacy properties format with value")}`, () => {
const customerId = "track-legacy1";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
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 have initial balance of 100", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(100);
});
test("should deduct value from properties object", async () => {
const initialBalance = 100;
const deductValue = 35.82;
// Legacy format: properties.value instead of value
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
properties: {
value: deductValue,
},
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
const expectedBalance = new Decimal(initialBalance)
.sub(deductValue)
.toNumber();
expect(balance).toBe(expectedBalance);
expect(usage).toBe(deductValue);
});
});

View File

@@ -1,130 +0,0 @@
import { ApiVersion, ProductItemFeatureType, type Organization } from "@autumn/shared";
import type { AppEnv, Autumn } from "autumn-js";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { TestFeature } from "tests/setup/v2Features.js";
const testCase = "trackMisc1";
const customerId = `${testCase}_cus1`;
const pro = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Messages, includedUsage: 5, featureType: ProductItemFeatureType.SingleUse })],
type: "pro",
})
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track consumable usage`)}`, () => {
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
let stripeCli: Stripe;
let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let autumnJs: Autumn;
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumnJs = this.autumnJs;
try {
await (autumnInt as AutumnInt).customers.delete(customerId);
} catch (_) {}
await addPrefixToProducts({
products: [pro],
prefix: testCase,
})
await createProducts({
autumn: autumnInt,
products: [pro],
customerId,
db,
orgId: org.id,
env,
})
});
it("should create a customer and issue balances", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId,
org,
env,
db,
attachPm: "success",
})
expect(customer).to.exist;
expect(customer.id).to.equal(customerId);
expect(customer.name).to.equal(customerId);
expect(customer.email).to.equal(`${customerId}@example.com`);
await autumnJs.attach({
customer_id: customerId,
product_id: pro.id,
})
});
it("should allow all requests to pass and cap balance at 0", async () => {
const customer = await autumnInt.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).to.equal(5, `Balance should be 5, got ${balance} | Balances: ${JSON.stringify(customer.features)}`);
const promises = [
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
];
let results = await Promise.allSettled(promises);
// With free feature capping, all requests pass (cap at 0 instead of rejecting)
expect(results.every(r => r.status === "fulfilled")).to.equal(true, `${results.map(r => r.status).join(", ")} <- all should pass`);
const { data: balances, error } = await autumnJs.customers.get(
customerId,
);
expect(error).to.be.null;
expect(balances?.features[TestFeature.Messages]?.balance).to.equal(
0,
`Balance should cap at 0, got ${balances?.features[TestFeature.Messages]?.balance}`,
);
expect(balances?.features[TestFeature.Messages]?.usage).to.equal(
5,
`Usage should be 5 (only what was available), got ${balances?.features[TestFeature.Messages]?.usage}`,
);
});
});

View File

@@ -1,146 +0,0 @@
import {
ApiVersion,
type Organization,
ProductItemFeatureType,
} from "@autumn/shared";
import type { AppEnv, Autumn } from "autumn-js";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
const testCase = "trackMisc2";
const customerId = `${testCase}_cus1`;
const pro = constructProduct({
id: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 1,
featureType: ProductItemFeatureType.ContinuousUse,
}),
],
type: "pro",
});
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track allocated feature with concurrent requests`)}`, () => {
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
let stripeCli: Stripe;
const autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let autumnJs: Autumn;
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumnJs = this.autumnJs;
try {
await (autumnInt as AutumnInt).customers.delete(customerId);
} catch (_) {}
await addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn: autumnInt,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
});
it("should create a customer and issue balances", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId,
org,
env,
db,
attachPm: "success",
});
expect(customer).to.exist;
expect(customer.id).to.equal(customerId);
expect(customer.name).to.equal(customerId);
expect(customer.email).to.equal(`${customerId}@example.com`);
await autumnJs.attach({
customer_id: customerId,
product_id: pro.id,
});
});
it("should only allow one concurrent track with balance of 1", async () => {
const customer = await autumnInt.customers.get(customerId);
const balance = customer.features[TestFeature.Users].balance;
expect(balance).to.equal(
1,
`Balance should be 1, got ${balance} | Balances: ${JSON.stringify(customer.features)}`,
);
const promises = [
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
];
const results = await Promise.allSettled(promises);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rejectedCount = results.filter((r) => r.status === "rejected").length;
expect(successCount).to.equal(
1,
`Expected exactly 1 success, got ${successCount} | Results: ${results.map((r) => r.status).join(", ")}`,
);
expect(rejectedCount).to.equal(
4,
`Expected exactly 4 rejections, got ${rejectedCount} | Results: ${results.map((r) => r.status).join(", ")}`,
);
const { data: balances, error } = await autumnJs.customers.get(customerId);
expect(error).to.be.null;
expect(balances?.features[TestFeature.Users]?.balance).to.equal(
0,
`Balance should be 0 (allocated feature), got ${balances?.features[TestFeature.Users]?.balance}`,
);
});
});

View File

@@ -1,166 +0,0 @@
import {
ApiVersion,
type Organization,
ProductItemFeatureType,
} from "@autumn/shared";
import type { AppEnv, Autumn } from "autumn-js";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
const testCase = "trackMisc3";
const customerId = `${testCase}_cus1`;
const userItem = constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 1,
featureType: ProductItemFeatureType.ContinuousUse,
});
const pro = constructProduct({
id: "pro",
items: [userItem],
type: "pro",
});
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track prepaid allocated feature with concurrent requests`)}`, () => {
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
let stripeCli: Stripe;
const autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let autumnJs: Autumn;
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumnJs = this.autumnJs;
try {
await (autumnInt as AutumnInt).customers.delete(customerId);
} catch (_) {}
await addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn: autumnInt,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
});
it("should create a customer and issue balances", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId,
org,
env,
db,
attachPm: "success",
});
expect(customer).to.exist;
expect(customer.id).to.equal(customerId);
expect(customer.name).to.equal(customerId);
expect(customer.email).to.equal(`${customerId}@example.com`);
await autumnJs.attach({
customer_id: customerId,
product_id: pro.id,
});
});
it("should only allow one concurrent seat allocation with 1 included seat and create no duplicate invoices", async () => {
const customer = await autumnInt.customers.get(customerId);
const balance = customer.features[TestFeature.Users].balance;
expect(balance).to.equal(
1,
`Balance should be 1, got ${balance} | Balances: ${JSON.stringify(customer.features)}`,
);
const initialInvoices = await stripeCli.invoices.list({
customer: customer.stripe_id as string,
});
const initialInvoiceCount = initialInvoices.data.length;
// Try to allocate 5 different seats concurrently - only 1 should succeed (the included seat)
// The other 4 should be rejected because we only have 1 included seat
const promises = [
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
}),
];
const results = await Promise.allSettled(promises);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rejectedCount = results.filter((r) => r.status === "rejected").length;
expect(successCount).to.equal(
1,
`Expected exactly 1 success (included seat), got ${successCount} | Results: ${results.map((r) => r.status).join(", ")}`,
);
expect(rejectedCount).to.equal(
4,
`Expected exactly 4 rejections (exceeded included), got ${rejectedCount} | Results: ${results.map((r) => r.status).join(", ")}`,
);
const { data: balances, error } = await autumnJs.customers.get(customerId);
expect(error).to.be.null;
expect(balances?.features[TestFeature.Users]?.balance).to.equal(
0,
`Balance should be 0 (allocated feature), got ${balances?.features[TestFeature.Users]?.balance}`,
);
// Verify no duplicate invoices were created
// Since we only allocated the 1 included seat, no overage charges should occur
const finalInvoices = await stripeCli.invoices.list({
customer: customer.stripe_id as string,
});
const finalInvoiceCount = finalInvoices.data.length;
const newInvoicesCreated = finalInvoiceCount - initialInvoiceCount;
expect(newInvoicesCreated).to.equal(
0,
`Expected 0 new invoices (only used included seat), got ${newInvoicesCreated}. Initial: ${initialInvoiceCount}, Final: ${finalInvoiceCount}`,
);
});
});

View File

@@ -1,210 +0,0 @@
import { ApiVersion, type Organization } from "@autumn/shared";
import type { AppEnv, Autumn } from "autumn-js";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
const testCase = "trackMisc4";
const customerId = `${testCase}_cus1`;
const messageItem = constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 5,
price: 0.1,
billingUnits: 1,
usageLimit: 10,
});
const pro = constructProduct({
id: "pro",
items: [messageItem],
type: "pro",
});
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing usage_limits with PayPerUse feature and concurrent requests`)}`, () => {
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
let stripeCli: Stripe;
const autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let autumnJs: Autumn;
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumnJs = this.autumnJs;
try {
await (autumnInt as AutumnInt).customers.delete(customerId);
} catch (_) {}
await addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn: autumnInt,
products: [pro],
customerId,
db,
orgId: org.id,
env,
}).catch((_) => {});
});
it("should create a customer and issue balances", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId,
org,
env,
db,
attachPm: "success",
});
expect(customer).to.exist;
expect(customer.id).to.equal(customerId);
expect(customer.name).to.equal(customerId);
expect(customer.email).to.equal(`${customerId}@example.com`);
await autumnJs.attach({
customer_id: customerId,
product_id: pro.id,
});
});
it("should enforce usage_limit with concurrent requests", async () => {
const customer = await autumnInt.customers.get(customerId);
console.log("customer", customer);
const balance = customer.features[TestFeature.Messages].balance;
const usageLimit = customer.features[TestFeature.Messages].usage_limit;
expect(balance).to.equal(5, `Balance should be 5, got ${balance}`);
expect(usageLimit).to.equal(
10,
`Usage limit should be 10, got ${usageLimit}`,
);
console.log(
"🚀 Starting 5 concurrent track calls (3 units each) at exact same time...",
);
console.log(
` Initial state: balance=${balance}, usage_limit=${usageLimit} (max total usage in billing cycle)`,
);
// Try to use 3 units concurrently - with usage_limit of 10, only 3 requests can succeed (3x3=9 <= 10)
const promises = [
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
}),
];
const results = await Promise.allSettled(promises);
console.log("📊 Results breakdown:");
results.forEach((result, index) => {
if (result.status === "rejected") {
console.log(
` [${index}] ❌ REJECTED:`,
result.reason?.message || result.reason,
);
console.log(` Error code:`, result.reason?.code);
console.log(` Status code:`, result.reason?.statusCode);
} else {
console.log(
` [${index}] ✅ FULFILLED (HTTP 200):`,
JSON.stringify(result.value),
);
}
});
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rejectedCount = results.filter((r) => r.status === "rejected").length;
console.log(
`\n📈 Summary: ${successCount} HTTP 200 responses, ${rejectedCount} HTTP errors`,
);
expect(successCount).to.equal(3, `Expected exactly 3 HTTP 200 responses, got ${successCount}`);
expect(rejectedCount).to.equal(2, `Expected exactly 2 HTTP errors (usage_limit exceeded), got ${rejectedCount}`);
// Wait for any async processing to complete
console.log(`⏳ Waiting 3s for all updates to persist...`);
await new Promise((resolve) => setTimeout(resolve, 3000));
const { data: balances, error } = await autumnJs.customers.get(customerId);
console.log(`📦 Final state after all requests:`);
console.log(
`- Balance: ${balances?.features[TestFeature.Messages]?.balance} (expected: -4)`,
);
console.log(
`- Usage: ${balances?.features[TestFeature.Messages]?.usage} (expected: 9)`,
);
console.log(
`- Usage limit: ${balances?.features[TestFeature.Messages]?.usage_limit} (expected: 10)`,
);
expect(balances?.features[TestFeature.Messages]?.balance).to.equal(
-4,
`Balance should be -4 (5 included - 9 used), got ${balances?.features[TestFeature.Messages]?.balance}`,
);
expect(balances?.features[TestFeature.Messages]?.usage).to.equal(
9,
`Usage should be 9, got ${balances?.features[TestFeature.Messages]?.usage}`,
);
expect(balances?.features[TestFeature.Messages]?.usage_limit).to.equal(
10,
`Usage limit should remain 10, got ${balances?.features[TestFeature.Messages]?.usage_limit}`,
);
// With usage_limit of 10, only 3 requests of value 3 can succeed (9 total)
// The 4th request would bring total to 12, exceeding the usage_limit
// expect(successCount).to.equal(3, `Expected exactly 3 successes (3x3=9 <= usage_limit of 10), got ${successCount} | Results: ${results.map(r => r.status).join(", ")}`);
// expect(rejectedCount).to.equal(2, `Expected exactly 2 rejections, got ${rejectedCount} | Results: ${results.map(r => r.status).join(", ")}`);
// expect(error).to.be.null;
// Balance consumed from included: min(9, 5) = 5, so balance = 0
// The remaining 4 units (9 - 5) are overages charged via PayPerUse
// expect(balances?.features[TestFeature.Messages]?.balance).to.equal(
// 0,
// `Balance should be 0 (all 5 included used), got ${balances?.features[TestFeature.Messages]?.balance}`,
// );
// expect(balances?.features[TestFeature.Messages]?.usage_limit).to.equal(
// 10,
// `Usage limit should remain 10, got ${balances?.features[TestFeature.Messages]?.usage_limit}`,
// );
});
});

View File

@@ -1,211 +0,0 @@
import { ApiVersion, ProductItemFeatureType, type Organization } from "@autumn/shared";
import type { AppEnv, Autumn } from "autumn-js";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem, constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { TestFeature } from "tests/setup/v2Features.js";
const testCase = "trackMisc5";
const customerId = `${testCase}_cus1`;
const seatItem = constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 5,
featureType: ProductItemFeatureType.ContinuousUse,
});
const perSeatMessagesItem = constructArrearItem({
featureId: TestFeature.Messages,
entityFeatureId: TestFeature.Users,
price: 0.01,
includedUsage: 500,
usageLimit: 600,
});
const pro = constructProduct({
id: "pro",
items: [seatItem, perSeatMessagesItem],
type: "pro",
})
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing per-entity trackMisc track with concurrent requests`)}`, () => {
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
let stripeCli: Stripe;
let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let autumnJs: Autumn;
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumnJs = this.autumnJs;
try {
await (autumnInt as AutumnInt).customers.delete(customerId);
} catch (_) {}
await addPrefixToProducts({
products: [pro],
prefix: testCase,
})
await createProducts({
autumn: autumnInt,
products: [pro],
customerId,
db,
orgId: org.id,
env,
})
});
it("should create a customer and issue balances", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId,
org,
env,
db,
attachPm: "success",
})
expect(customer).to.exist;
expect(customer.id).to.equal(customerId);
expect(customer.name).to.equal(customerId);
expect(customer.email).to.equal(`${customerId}@example.com`);
await autumnJs.attach({
customer_id: customerId,
product_id: pro.id,
})
});
it("should create 5 seats each with 500 messages", async () => {
const customer = await autumnInt.customers.get(customerId);
const seatBalance = customer.features[TestFeature.Users].balance;
expect(seatBalance).to.equal(5, `Seat balance should be 5, got ${seatBalance}`);
// Create 5 entities (seats)
const entities = [
{ id: "seat1", name: "Seat 1" },
{ id: "seat2", name: "Seat 2" },
{ id: "seat3", name: "Seat 3" },
{ id: "seat4", name: "Seat 4" },
{ id: "seat5", name: "Seat 5" },
];
for (const entity of entities) {
await autumnInt.entities.create(customerId, {
id: entity.id,
name: entity.name,
feature_id: TestFeature.Users,
});
}
// Verify each seat has 500 messages
const updatedEntity = await autumnInt.entities.get(customerId, entities[0].id);
console.log(JSON.stringify(updatedEntity, null, 4));
expect(updatedEntity.features[TestFeature.Messages].balance).to.equal(500, JSON.stringify(updatedEntity, null, 4));
});
it("should enforce usage_limit of 600 per seat with concurrent 200-unit requests", async () => {
const entityId = "seat1";
// Verify seat1 has 500 included messages with 600 usage_limit
const entityRes = await autumnInt.entities.get(customerId, entityId);
expect(entityRes.features[TestFeature.Messages].balance).to.equal(500);
// expect(entityRes.features[TestFeature.Messages].usage_limit).to.equal(600);
console.log("🚀 Starting 5 concurrent track calls (200 units each) for seat1...");
console.log(` Initial state: balance=${entityRes.features[TestFeature.Messages].balance}, usage_limit=${entityRes.features[TestFeature.Messages].usage_limit}`);
// Try 5 concurrent 200-unit sends to seat1
// With usage_limit of 600, only 3 should succeed (3×200=600 <= 600)
const promises = [
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
}),
autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entityId,
value: 200,
}),
];
let results = await Promise.allSettled(promises);
console.log("📊 Results breakdown:");
results.forEach((result, index) => {
if (result.status === "rejected") {
console.log(` [${index}] ❌ REJECTED:`, result.reason?.message || result.reason);
console.log(` Error code:`, result.reason?.code);
} else {
console.log(` [${index}] ✅ FULFILLED (HTTP 200):`, JSON.stringify(result.value));
}
});
const successCount = results.filter(r => r.status === "fulfilled").length;
const rejectedCount = results.filter(r => r.status === "rejected").length;
console.log(`\n📈 Summary: ${successCount} HTTP 200 responses, ${rejectedCount} HTTP errors`);
expect(successCount).to.equal(3, `Expected exactly 3 HTTP 200 responses, got ${successCount}`);
expect(rejectedCount).to.equal(2, `Expected exactly 2 HTTP errors (usage_limit exceeded), got ${rejectedCount}`);
// Get final state
const finalEntityRes = await autumnInt.entities.get(customerId, entityId);
console.log(`\n📦 Final state for ${entityId}:`);
console.log(`- Balance: ${finalEntityRes.features[TestFeature.Messages].balance} (expected: -100)`);
console.log(`- Usage: ${finalEntityRes.features[TestFeature.Messages].usage} (expected: 600)`);
console.log(`- Usage limit: ${finalEntityRes.features[TestFeature.Messages].usage_limit} (expected: 600)`);
expect(finalEntityRes.features[TestFeature.Messages].balance).to.equal(
-100,
`Balance should be -100 (500 included - 600 used), got ${finalEntityRes.features[TestFeature.Messages].balance}`,
);
expect(finalEntityRes.features[TestFeature.Messages].usage).to.equal(
600,
`Usage should be 600, got ${finalEntityRes.features[TestFeature.Messages].usage}`,
);
// Verify other seats remain untouched at 500
for (const seatId of ["seat2", "seat3", "seat4", "seat5"]) {
const otherSeatRes = await autumnInt.entities.get(customerId, seatId);
expect(otherSeatRes.features[TestFeature.Messages].balance).to.equal(
500,
`${seatId} should still have 500 messages, got ${otherSeatRes.features[TestFeature.Messages].balance}`,
);
}
});
});

View File

@@ -1,226 +0,0 @@
import { ApiVersion, type Organization } from "@autumn/shared";
import type { AppEnv, Autumn } from "autumn-js";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem, constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { TestFeature } from "tests/setup/v2Features.js";
const testCase = "trackMisc6";
const prepaidCustomerId = `${testCase}_prepaid_cus`;
const payPerUseCustomerId = `${testCase}_payperuse_cus`;
// Prepaid feature: 5 included, no overage allowed
const prepaidItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 5,
});
// PayPerUse feature: 5 included, overage allowed at $0.01 per unit, usage_limit of 10
const payPerUseItem = constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 5,
price: 0.01,
billingUnits: 1,
usageLimit: 10,
});
const prepaidProduct = constructProduct({
id: "prepaid",
items: [prepaidItem],
type: "pro",
});
const payPerUseProduct = constructProduct({
id: "payperuse",
items: [payPerUseItem],
type: "pro",
});
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing prepaid vs PayPerUse overage behavior`)}`, () => {
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
let stripeCli: Stripe;
let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let autumnJs: Autumn;
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumnJs = this.autumnJs;
// Delete both customers
try {
await (autumnInt as AutumnInt).customers.delete(prepaidCustomerId);
} catch (_) {}
try {
await (autumnInt as AutumnInt).customers.delete(payPerUseCustomerId);
} catch (_) {}
await addPrefixToProducts({
products: [prepaidProduct, payPerUseProduct],
prefix: testCase,
})
// Create products for prepaid customer
await createProducts({
autumn: autumnInt,
products: [prepaidProduct],
customerId: prepaidCustomerId,
db,
orgId: org.id,
env,
})
// Create products for pay-per-use customer
await createProducts({
autumn: autumnInt,
products: [payPerUseProduct],
customerId: payPerUseCustomerId,
db,
orgId: org.id,
env,
})
});
it("should create prepaid customer and attach product", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId: prepaidCustomerId,
org,
env,
db,
attachPm: "success",
})
expect(customer).to.exist;
expect(customer.id).to.equal(prepaidCustomerId);
await autumnJs.attach({
customer_id: prepaidCustomerId,
product_id: prepaidProduct.id,
})
});
it("should create pay-per-use customer and attach product", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId: payPerUseCustomerId,
org,
env,
db,
attachPm: "success",
})
expect(customer).to.exist;
expect(customer.id).to.equal(payPerUseCustomerId);
await autumnJs.attach({
customer_id: payPerUseCustomerId,
product_id: payPerUseProduct.id,
})
});
it("should reject tracking 7 units when prepaid balance is 5 (no overage)", async () => {
const customer = await autumnInt.customers.get(prepaidCustomerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).to.equal(5, `Balance should be 5, got ${balance}`);
console.log("🚀 Tracking 7 units with prepaid balance of 5 (no overage allowed)...");
let error: any = null;
try {
await autumnInt.track({
customer_id: prepaidCustomerId,
feature_id: TestFeature.Messages,
value: 7,
});
} catch (e) {
error = e;
}
expect(error).to.exist;
expect(error.message).to.include("Insufficient balance");
expect(error.message).to.include("Available: 5");
expect(error.message).to.include("Required: 7");
console.log("❌ Request rejected:", error.message);
// Verify balance remains unchanged
const finalCustomer = await autumnInt.customers.get(prepaidCustomerId);
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
console.log(`📦 Final balance: ${finalBalance} (expected: 5)`);
expect(finalBalance).to.equal(5, `Balance should remain 5, got ${finalBalance}`);
});
it("should allow tracking 7 units when PayPerUse balance is 5 (overage allowed)", async () => {
const customer = await autumnInt.customers.get(payPerUseCustomerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).to.equal(5, `Balance should be 5, got ${balance}`);
console.log("📊 Customer feature details:", JSON.stringify(customer.features[TestFeature.Messages], null, 2));
// Get initial invoice count
const initialInvoices = await stripeCli.invoices.list({
customer: customer.stripe_id as string,
});
const initialInvoiceCount = initialInvoices.data.length;
console.log("🚀 Tracking 7 units with PayPerUse balance of 5 (overage allowed)...");
let error: any = null;
let response: any = null;
try {
response = await autumnInt.track({
customer_id: payPerUseCustomerId,
feature_id: TestFeature.Messages,
value: 7,
});
} catch (e) {
error = e;
}
expect(error).to.be.null;
expect(response).to.exist;
console.log("✅ Request succeeded:", JSON.stringify(response));
// Wait for processing (even though it should be synchronous with the PR changes)
await new Promise(resolve => setTimeout(resolve, 3000));
// Verify balance went negative (overage)
const finalCustomer = await autumnInt.customers.get(payPerUseCustomerId);
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
const finalUsage = finalCustomer.features[TestFeature.Messages].usage;
console.log(`📦 Final balance: ${finalBalance} (expected: -2)`);
console.log(`📦 Final usage: ${finalUsage} (expected: 7)`);
expect(finalBalance).to.equal(-2, `Balance should be -2 (5 included - 7 used), got ${finalBalance}`);
expect(finalUsage).to.equal(7, `Usage should be 7, got ${finalUsage}`);
// Note: Invoices may be created async or on billing cycle
// For now, we just log the invoice count
const finalInvoices = await stripeCli.invoices.list({
customer: customer.stripe_id as string,
});
const finalInvoiceCount = finalInvoices.data.length;
const newInvoicesCreated = finalInvoiceCount - initialInvoiceCount;
console.log(`💳 Invoices: ${newInvoicesCreated} new invoice(s) created (may be 0 if invoiced later)`);
if (newInvoicesCreated > 0) {
const latestInvoice = finalInvoices.data[0];
console.log(` Invoice total: $${(latestInvoice.total / 100).toFixed(2)} (expected: 2 units × $0.01 = $0.02)`);
}
});
});

View File

@@ -1,105 +0,0 @@
import { AllowanceType, ApiVersion, Infinite, type Organization } from "@autumn/shared";
import type { AppEnv, Autumn } from "autumn-js";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { TestFeature } from "tests/setup/v2Features.js";
const testCase = "trackMisc7";
const customerId = `${testCase}_cus1`;
// Free feature (included only, no price) - should cap at 0
const freeItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 50,
});
const pro = constructProduct({
id: "pro",
items: [freeItem],
type: "pro",
});
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing free balance capping`)}`, () => {
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
let stripeCli: Stripe;
let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let autumnJs: Autumn;
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumnJs = this.autumnJs;
try {
await autumnInt.customers.delete(customerId);
} catch (_) {}
await addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn: autumnInt,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
});
it("should create customer and attach product", async () => {
const { customer } = await initCustomerV2({
autumn: autumnInt,
customerId,
org,
env,
db,
attachPm: "success",
});
await autumnJs.attach({
customer_id: customerId,
product_id: pro.id,
});
expect(customer).to.exist;
expect(customer.id).to.equal(customerId);
});
it("should cap free balance at 0 when tracking more than available", async () => {
const customer = await autumnInt.customers.get(customerId);
const initialBalance = customer.features[TestFeature.Messages].balance;
expect(initialBalance).to.equal(50, `Initial balance should be 50, got ${initialBalance}`);
console.log(`🚀 Tracking 60 units with free balance of 50 (should cap at 0)...`);
await autumnInt.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 60,
});
const finalCustomer = await autumnInt.customers.get(customerId);
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
const finalUsage = finalCustomer.features[TestFeature.Messages].usage;
console.log(`📦 Final state: balance=${finalBalance}, usage=${finalUsage}`);
expect(finalBalance).to.equal(0, `Balance should cap at 0, got ${finalBalance}`);
expect(finalUsage).to.equal(50, `Usage should be 50, got ${finalUsage}`);
});
});

View File

@@ -0,0 +1,5 @@
import { SuccessCode, type TrackResponse } from "@autumn/shared";
export const trackWasSuccessful = ({ res }: { res: TrackResponse }) => {
return res.code === SuccessCode.EventReceived;
};

View File

@@ -20,6 +20,9 @@ export enum TestFeature {
Action1 = "action1", // single use (pay per use) Action1 = "action1", // single use (pay per use)
Action2 = "action2", // single use (pay per use) Action2 = "action2", // single use (pay per use)
Credits = "credits", // credit system Credits = "credits", // credit system
Action3 = "action3", // single use (pay per use)
Credits2 = "credits2", // credit system
} }
const orgId = process.env.TESTS_ORG_ID!; const orgId = process.env.TESTS_ORG_ID!;
@@ -64,12 +67,21 @@ export const features = {
orgId, orgId,
env: AppEnv.Sandbox, env: AppEnv.Sandbox,
usageType: FeatureUsageType.Single, usageType: FeatureUsageType.Single,
eventNames: ["action-event"],
}), }),
[TestFeature.Action2]: constructMeteredFeature({ [TestFeature.Action2]: constructMeteredFeature({
featureId: TestFeature.Action2, featureId: TestFeature.Action2,
orgId, orgId,
env: AppEnv.Sandbox, env: AppEnv.Sandbox,
usageType: FeatureUsageType.Single, usageType: FeatureUsageType.Single,
// eventNames: ["action-event"],
}),
[TestFeature.Action3]: constructMeteredFeature({
featureId: TestFeature.Action3,
orgId,
env: AppEnv.Sandbox,
usageType: FeatureUsageType.Single,
eventNames: ["action-event"],
}), }),
[TestFeature.Credits]: constructCreditSystem({ [TestFeature.Credits]: constructCreditSystem({
featureId: TestFeature.Credits, featureId: TestFeature.Credits,
@@ -86,4 +98,15 @@ export const features = {
}, },
], ],
}), }),
[TestFeature.Credits2]: constructCreditSystem({
featureId: TestFeature.Credits2,
orgId,
env: AppEnv.Sandbox,
schema: [
{
metered_feature_id: TestFeature.Action3,
credit_cost: 1.4,
},
],
}),
}; };

View File

@@ -60,6 +60,9 @@ export const TrackParamsSchema = z
entity_data: EntityDataSchema.optional().meta({ entity_data: EntityDataSchema.optional().meta({
description: "Data for creating the entity if it doesn't exist", description: "Data for creating the entity if it doesn't exist",
}), }),
overage_behaviour: z.enum(["cap", "reject"]).optional().meta({
description: "The behavior when the balance is insufficient",
}),
}) })
.refine( .refine(
(data) => { (data) => {
@@ -78,7 +81,7 @@ export const TrackParamsSchema = z
}, },
); );
export const TrackResultSchema = z.object({ export const TrackResponseSchema = z.object({
id: z.string().meta({ id: z.string().meta({
description: "The ID of the created event", description: "The ID of the created event",
}), }),
@@ -100,3 +103,4 @@ export const TrackResultSchema = z.object({
}); });
export type TrackParams = z.infer<typeof TrackParamsSchema>; export type TrackParams = z.infer<typeof TrackParamsSchema>;
export type TrackResponse = z.infer<typeof TrackResponseSchema>;

View File

@@ -20,3 +20,5 @@ export const SetUsageParamsSchema = z.object({
customer_data: CustomerDataSchema.optional(), customer_data: CustomerDataSchema.optional(),
}); });
export type SetUsageParams = z.infer<typeof SetUsageParamsSchema>;

View File

@@ -0,0 +1,13 @@
import { RecaseError } from "../../../index.js";
import { BalancesErrorCode } from "../codes/balancesErrCodes.js";
export class InsufficientBalanceError extends RecaseError {
constructor(opts?: { message?: string }) {
super({
message: opts?.message || "Insufficient balance",
code: BalancesErrorCode.InsufficientBalance,
statusCode: 400,
});
this.name = "InsufficientBalanceError";
}
}

View File

@@ -0,0 +1,6 @@
export const BalancesErrorCode = {
InsufficientBalance: "insufficient_balance",
} as const;
export type BalancesErrorCode =
(typeof BalancesErrorCode)[keyof typeof BalancesErrorCode];

View File

@@ -1,8 +1,10 @@
export * from "./base/InternalError.js"; export * from "./base/InternalError.js";
export * from "./base/RecaseError.js"; export * from "./base/RecaseError.js";
export * from "./classes/balancesErrClasses.js";
export * from "./classes/cusErrClasses.js"; export * from "./classes/cusErrClasses.js";
export * from "./classes/cusProductErrClasses.js"; export * from "./classes/cusProductErrClasses.js";
export * from "./classes/productErrClasses.js"; export * from "./classes/productErrClasses.js";
export * from "./codes/balancesErrCodes.js";
export * from "./codes/cusErrCodes.js"; export * from "./codes/cusErrCodes.js";
export * from "./codes/cusProductErrCodes.js"; export * from "./codes/cusProductErrCodes.js";
export * from "./codes/productErrCodes.js"; export * from "./codes/productErrCodes.js";

View File

@@ -61,6 +61,7 @@ export * from "./referrals/referralsOpenApi.js";
// Balances // Balances
export * from "./balances/check/previousVersions/CheckResponseV0.js"; export * from "./balances/check/previousVersions/CheckResponseV0.js";
export * from "./balances/trackModels.js"; export * from "./balances/trackModels.js";
export * from "./balances/usageModels.js";
// Errors // Errors
export * from "./errors/index.js"; export * from "./errors/index.js";
// Models // Models

View File

@@ -1,5 +1,6 @@
export enum SuccessCode { export enum SuccessCode {
// Events // Track
SuccessfullyDeducted = "successfully_deducted",
EventReceived = "event_received", EventReceived = "event_received",
EventReceivedCustomerCreated = "event_received_customer_created", EventReceivedCustomerCreated = "event_received_customer_created",

View File

@@ -1,4 +1,6 @@
import { Decimal } from "decimal.js";
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { notNullish, nullish } from "../utils.js"; import { notNullish, nullish } from "../utils.js";
export const getSummedEntityBalances = ({ export const getSummedEntityBalances = ({
@@ -67,3 +69,22 @@ export const getCusEntBalance = ({
count: 1, count: 1,
}; };
}; };
export const getMaxOverage = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}) => {
const usageLimit = cusEnt.entitlement.usage_limit;
if (nullish(usageLimit)) return undefined;
// const cusPrice = cusEntToCusPrice({ cusEnt });
// if (cusPrice && isPrepaidPrice({ price: cusPrice.price })) return undefined;
if (!cusEnt.usage_allowed) return undefined;
const maxOverage = new Decimal(usageLimit)
.sub(cusEnt.balance || 0)
.toNumber();
return maxOverage;
};

View File

@@ -1,4 +1,7 @@
import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; import type {
EntityBalance,
FullCustomerEntitlement,
} from "@models/cusProductModels/cusEntModels/cusEntModels.js";
export const formatCusEnt = ({ export const formatCusEnt = ({
cusEnt, cusEnt,
@@ -7,3 +10,37 @@ export const formatCusEnt = ({
}) => { }) => {
return `${cusEnt.entitlement.feature_id} (${cusEnt.entitlement.interval}) (${cusEnt.balance})`; return `${cusEnt.entitlement.feature_id} (${cusEnt.entitlement.interval}) (${cusEnt.balance})`;
}; };
import type { FullCustomer } from "@autumn/shared";
export const updateCusEntInFullCus = ({
fullCus,
cusEntId,
update,
}: {
fullCus: FullCustomer;
cusEntId: string;
update: {
balance: number;
entities: Record<string, EntityBalance> | undefined;
adjustment: number;
};
}) => {
for (let i = 0; i < fullCus.customer_products.length; i++) {
for (
let j = 0;
j < fullCus.customer_products[i].customer_entitlements.length;
j++
) {
const ce = fullCus.customer_products[i].customer_entitlements[j];
if (ce.id === cusEntId) {
fullCus.customer_products[i].customer_entitlements[j] = {
...ce,
balance: update.balance,
entities: update.entities,
adjustment: update.adjustment,
};
}
}
}
};

View File

@@ -1,5 +1,5 @@
import { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import { FeatureType } from "../../models/featureModels/featureEnums.js"; import { FeatureType } from "../../models/featureModels/featureEnums.js";
import { AllowanceType } from "../../models/productModels/entModels/entModels.js"; import { AllowanceType } from "../../models/productModels/entModels/entModels.js";
import { entIntervalToValue } from "../intervalUtils.js"; import { entIntervalToValue } from "../intervalUtils.js";
@@ -15,41 +15,41 @@ export const sortCusEntsForDeduction = (
const bEnt = b.entitlement; const bEnt = b.entitlement;
// 1. If boolean, go first // 1. If boolean, go first
if (aEnt.feature.type == FeatureType.Boolean) { if (aEnt.feature.type === FeatureType.Boolean) {
return -1; return -1;
} }
if (bEnt.feature.type == FeatureType.Boolean) { if (bEnt.feature.type === FeatureType.Boolean) {
return 1; return 1;
} }
// 1. If a is credit system and b is not, a should go last // 1. If a is credit system and b is not, a should go last
if ( if (
aEnt.feature.type == FeatureType.CreditSystem && aEnt.feature.type === FeatureType.CreditSystem &&
bEnt.feature.type != FeatureType.CreditSystem bEnt.feature.type !== FeatureType.CreditSystem
) { ) {
return 1; return 1;
} }
// 2. If a is not credit system and b is, a should go first // 2. If a is not credit system and b is, a should go first
if ( if (
aEnt.feature.type != FeatureType.CreditSystem && aEnt.feature.type !== FeatureType.CreditSystem &&
bEnt.feature.type == FeatureType.CreditSystem bEnt.feature.type === FeatureType.CreditSystem
) { ) {
return -1; return -1;
} }
// 2. Sort by unlimited (unlimited goes first) // 2. Sort by unlimited (unlimited goes first)
if ( if (
aEnt.allowance_type == AllowanceType.Unlimited && aEnt.allowance_type === AllowanceType.Unlimited &&
bEnt.allowance_type != AllowanceType.Unlimited bEnt.allowance_type !== AllowanceType.Unlimited
) { ) {
return -1; return -1;
} }
if ( if (
aEnt.allowance_type != AllowanceType.Unlimited && aEnt.allowance_type !== AllowanceType.Unlimited &&
bEnt.allowance_type == AllowanceType.Unlimited bEnt.allowance_type === AllowanceType.Unlimited
) { ) {
return 1; return 1;
} }
@@ -64,7 +64,7 @@ export const sortCusEntsForDeduction = (
} }
// If one has a next_reset_at, it should go first // If one has a next_reset_at, it should go first
let nextResetFirst = reverseOrder ? 1 : -1; const nextResetFirst = reverseOrder ? 1 : -1;
if (a.next_reset_at && !b.next_reset_at) { if (a.next_reset_at && !b.next_reset_at) {
return nextResetFirst; return nextResetFirst;
@@ -76,8 +76,8 @@ export const sortCusEntsForDeduction = (
} }
// 3. Sort by interval // 3. Sort by interval
let aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count); const aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count);
let bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count); const bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count);
if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) { if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) {
if (reverseOrder) { if (reverseOrder) {
return bVal.sub(aVal).toNumber(); return bVal.sub(aVal).toNumber();
@@ -89,8 +89,8 @@ export const sortCusEntsForDeduction = (
} }
// Check if a is main product // Check if a is main product
let aIsAddOn = a.customer_product?.product?.is_add_on; const aIsAddOn = a.customer_product?.product?.is_add_on;
let bIsAddOn = b.customer_product?.product?.is_add_on; const bIsAddOn = b.customer_product?.product?.is_add_on;
if (aIsAddOn && !bIsAddOn) { if (aIsAddOn && !bIsAddOn) {
return 1; return 1;

View File

@@ -2,6 +2,7 @@ import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js"; import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js";
import { FeatureType } from "../models/featureModels/featureEnums.js"; import { FeatureType } from "../models/featureModels/featureEnums.js";
import type { Feature } from "../models/featureModels/featureModels.js"; import type { Feature } from "../models/featureModels/featureModels.js";
import { creditSystemContainsFeature } from "./featureUtils/creditSystemUtils.js";
// import { // import {
// constructBooleanFeature, // constructBooleanFeature,
// constructCreditSystem, // constructCreditSystem,
@@ -35,3 +36,20 @@ export const toApiFeature = ({ feature }: { feature: Feature }) => {
credit_schema: creditSchema, credit_schema: creditSchema,
}); });
}; };
export const getRelevantFeatures = ({
features,
featureId,
}: {
features: Feature[];
featureId: string;
}) => {
return features.filter(
(f) =>
f.id === featureId ||
creditSystemContainsFeature({
creditSystem: f,
meteredFeatureId: featureId,
}),
);
};

View File

@@ -0,0 +1,24 @@
import type { CreditSchemaItem } from "../../models/featureModels/featureConfig/creditConfig.js";
import { FeatureType } from "../../models/featureModels/featureEnums.js";
import type { Feature } from "../../models/featureModels/featureModels.js";
export const creditSystemContainsFeature = ({
creditSystem,
meteredFeatureId,
}: {
creditSystem: Feature;
meteredFeatureId: string;
}) => {
if (creditSystem.type !== FeatureType.CreditSystem) {
return false;
}
const schema: CreditSchemaItem[] = creditSystem.config.schema;
for (const schemaItem of schema) {
if (schemaItem.metered_feature_id === meteredFeatureId) {
return true;
}
}
return false;
};