diff --git a/server/shell/g5.sh b/server/shell/g5.sh index 11b1c5c1f..65866cc3f 100755 --- a/server/shell/g5.sh +++ b/server/shell/g5.sh @@ -8,6 +8,7 @@ if [[ "$1" == *"setup"* ]]; then MOCHA_PARALLEL=true $MOCHA_SETUP fi +$MOCHA_CMD 'tests/advanced/rollovers/*.ts' # $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ # 'tests/advanced/coupons/*.ts' \ # 'tests/attach/updateQuantity/*.ts' \ diff --git a/server/src/db/initDrizzle.ts b/server/src/db/initDrizzle.ts index 784e8beb2..613f8b591 100644 --- a/server/src/db/initDrizzle.ts +++ b/server/src/db/initDrizzle.ts @@ -1,15 +1,16 @@ import dotenv from "dotenv"; + dotenv.config(); -import postgres from "postgres"; -import { drizzle } from "drizzle-orm/postgres-js"; 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 let db = drizzle(client, { schema }); +export const client = postgres(process.env.DATABASE_URL!); +export const db = drizzle(client, { schema }); export const initDrizzle = (params?: { maxConnections?: number }) => { - let maxConnections = params?.maxConnections || 10; + const maxConnections = params?.maxConnections; const client = postgres(process.env.DATABASE_URL!, { max: maxConnections, }); diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 41f7629a3..8b17112f7 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -13,6 +13,7 @@ import { type LegacyVersion, type OrgConfig, type RewardRedemption, + type TrackParams, } from "@autumn/shared"; import type { CancelParams, @@ -21,7 +22,6 @@ import type { CheckParams, CheckResult, Customer, - TrackParams, UsageParams, } 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); return data; }; diff --git a/server/src/index.ts b/server/src/index.ts index e356e03d3..438cd78be 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -18,12 +18,16 @@ if (process.env.NODE_ENV !== "development") { } import cluster from "node:cluster"; +import { readFileSync } from "node:fs"; import http from "node:http"; import os from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { AppEnv } from "@autumn/shared"; import { context, trace } from "@opentelemetry/api"; import { toNodeHandler } from "better-auth/node"; import cors from "cors"; +import { sql } from "drizzle-orm"; import express from "express"; import { client, db } from "./db/initDrizzle.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 { checkEnvVars } from "./utils/initUtils.js"; +const __dirname = dirname(fileURLToPath(import.meta.url)); + const tracer = trace.getTracer("express"); checkEnvVars(); // 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 app = express(); const server = http.createServer(app); @@ -136,6 +172,9 @@ const init = async () => { ClickHouseManager.getInstance(), ]); + // Initialize database functions + await initializeDatabaseFunctions(); + app.use(async (req: any, res: any, next: any) => { req.env = req.env = req.headers.app_env || AppEnv.Sandbox; req.db = db; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 1a708f381..c23cfcd45 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -14,6 +14,7 @@ import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js"; import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js"; import type { HonoEnv } from "./honoUtils/HonoEnv.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 { cusRouter } from "./internal/customers/cusRouter.js"; import { internalCusRouter } from "./internal/customers/internalCusRouter.js"; @@ -95,6 +96,7 @@ export const createHonoApp = () => { // API Routes app.post("/v1/events", ...handleTrack); app.post("/v1/track", ...handleTrack); + app.post("/v1/usage", ...handleSetUsage); app.post("/v1/entitled", ...handleCheck); app.post("/v1/check", ...handleCheck); diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 2aa54480f..f5f2e6885 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -19,7 +19,6 @@ import { productBetaRouter, productRouter } from "../products/productRouter.js"; import { componentRouter } from "./components/componentRouter.js"; import { entityRouter } from "./entities/entityRouter.js"; // import { checkRouter } from "./entitled/checkRouter.js"; -import { usageRouter } from "./events/usageRouter.js"; import { invoiceRouter } from "./invoiceRouter.js"; import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js"; import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js"; @@ -42,7 +41,6 @@ apiRouter.use("/rewards", rewardRouter); apiRouter.use("/features", featureRouter); apiRouter.use("/internal_features", internalFeatureRouter); -apiRouter.use("/usage", usageRouter); apiRouter.use("/entities", entityRouter); apiRouter.use("/migrations", migrationRouter); @@ -57,6 +55,7 @@ apiRouter.use("/cancel", cancelRouter); // apiRouter.use("/entitled", checkRouter); // apiRouter.use("/check", checkRouter); +// apiRouter.use("/usage", usageRouter); // apiRouter.use("/events", eventsRouter); // apiRouter.use("/track", eventsRouter); diff --git a/server/src/internal/api/events/EventService.ts b/server/src/internal/api/events/EventService.ts index 0bee12324..6c44e6870 100644 --- a/server/src/internal/api/events/EventService.ts +++ b/server/src/internal/api/events/EventService.ts @@ -1,9 +1,8 @@ -import { ErrCode, EventInsert } from "@autumn/shared"; -import RecaseError from "@/utils/errorUtils.js"; +import { ErrCode, type EventInsert, events } from "@autumn/shared"; +import { and, desc, eq } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; -import { events } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { and, eq, desc } from "drizzle-orm"; +import RecaseError from "@/utils/errorUtils.js"; export class EventService { static async insert({ db, event }: { db: DrizzleCli; event: EventInsert }) { @@ -24,7 +23,7 @@ export class EventService { return results[0]; } catch (error: any) { - if (error.code == "23505") { + if (error.code === "23505") { throw new RecaseError({ message: "Event (event_name, customer_id, idempotency_key) already exists.", @@ -49,7 +48,7 @@ export class EventService { env: string; limit?: number; }) { - let results = await db + const results = await db .select({ id: events.id, event_name: events.event_name, diff --git a/server/src/internal/balances/setUsage/getSetUsageDeductions.ts b/server/src/internal/balances/setUsage/getSetUsageDeductions.ts new file mode 100644 index 000000000..aa7cd5e50 --- /dev/null +++ b/server/src/internal/balances/setUsage/getSetUsageDeductions.ts @@ -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 => { + 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; +}; diff --git a/server/src/internal/balances/setUsage/handleSetUsage.ts b/server/src/internal/balances/setUsage/handleSetUsage.ts new file mode 100644 index 000000000..791386372 --- /dev/null +++ b/server/src/internal/balances/setUsage/handleSetUsage.ts @@ -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 }); + }, +}); diff --git a/server/src/internal/balances/track/handleTrack.ts b/server/src/internal/balances/track/handleTrack.ts index 95c8546f9..68c022bcc 100644 --- a/server/src/internal/balances/track/handleTrack.ts +++ b/server/src/internal/balances/track/handleTrack.ts @@ -1,4 +1,8 @@ -import { TrackParamsSchema } from "@autumn/shared"; +import { + InsufficientBalanceError, + SuccessCode, + TrackParamsSchema, +} from "@autumn/shared"; import { createRoute } from "../../../honoMiddlewares/routeHandler.js"; import { getTrackEventNameDeductions, @@ -31,24 +35,48 @@ export const handleTrack = createRoute({ value: body.value, }); - 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, - }, - }); + try { + const start = Date.now(); + const { fullCus, event } = await runDeductionTx({ + ctx, + customerId: body.customer_id, + entityId: body.entity_id, + deductions: featureDeductions, + overageBehaviour: body.overage_behaviour, + 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}`); + const elapsed = Date.now() - start; + 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; + } }, }); diff --git a/server/src/internal/balances/track/trackUtils/DEDUCTION_GUIDE.md b/server/src/internal/balances/track/trackUtils/DEDUCTION_GUIDE.md new file mode 100644 index 000000000..9a509bd41 --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/DEDUCTION_GUIDE.md @@ -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 + diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/README.md b/server/src/internal/balances/track/trackUtils/deductRpc/README.md new file mode 100644 index 000000000..f0bf97f9b --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/deductRpc/README.md @@ -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. + diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/deductAllowance.sql b/server/src/internal/balances/track/trackUtils/deductRpc/deductAllowance.sql new file mode 100644 index 000000000..e4594d3ab --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/deductRpc/deductAllowance.sql @@ -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; +$$; diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromAllEntities.sql b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromAllEntities.sql new file mode 100644 index 000000000..8d95e55b6 --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromAllEntities.sql @@ -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; +$$; + diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromRollovers.sql b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromRollovers.sql new file mode 100644 index 000000000..9ad2f7e6a --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromRollovers.sql @@ -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; +$$; + diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromSingleEntity.sql b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromSingleEntity.sql new file mode 100644 index 000000000..0d506f594 --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromSingleEntity.sql @@ -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; +$$; + diff --git a/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts b/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts index d23e24df5..14c826878 100644 --- a/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts +++ b/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts @@ -1,9 +1,6 @@ import { type Feature, FeatureNotFoundError } from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { - getCreditCost, - getCreditSystemsFromFeature, -} from "../../../features/creditSystemUtils.js"; +import { getCreditSystemsFromFeature } from "../../../features/creditSystemUtils.js"; export type FeatureDeduction = { feature: Feature; @@ -43,18 +40,18 @@ export const getTrackFeatureDeductions = ({ deduction: mainFeatureDeduction, }); - for (const creditSystem of creditSystems) { - const creditSystemDeduction = getCreditCost({ - featureId: mainFeature.id, - creditSystem, - amount: mainFeatureDeduction, - }); + // for (const creditSystem of creditSystems) { + // const creditSystemDeduction = getCreditCost({ + // featureId: mainFeature.id, + // creditSystem, + // amount: mainFeatureDeduction, + // }); - featureDeductions.push({ - feature: creditSystem, - deduction: creditSystemDeduction, - }); - } + // featureDeductions.push({ + // feature: creditSystem, + // deduction: creditSystemDeduction, + // }); + // } return featureDeductions; }; diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index e73691bca..45d33d35b 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -1,48 +1,54 @@ +import type { Event } from "@autumn/shared"; import { CusProductStatus, + cusEntToCusPrice, cusProductsToCusEnts, - cusProductsToPrices, + cusProductsToCusPrices, + FeatureUsageType, + type FullCustomer, + getMaxOverage, + getRelevantFeatures, + InsufficientBalanceError, + InternalError, + notNullish, + nullish, + updateCusEntInFullCus, } from "@autumn/shared"; import { sql } from "drizzle-orm"; import type { DrizzleCli } from "../../../../db/initDrizzle.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { handleThresholdReached } from "../../../../trigger/handleThresholdReached.js"; -import { - deductAllowanceFromCusEnt, - deductFromUsageBasedCusEnt, -} from "../../../../trigger/updateBalanceTask.js"; +import { adjustAllowance } from "../../../../trigger/adjustAllowance.js"; import { EventService } from "../../../api/events/EventService.js"; import { CusService } from "../../../customers/CusService.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 type { FeatureDeduction } from "./getFeatureDeductions.js"; -import { validateDeductionPossible } from "./validateDeductionPossible.js"; export type DeductionTxParams = { ctx: AutumnContext; customerId: string; entityId?: string; 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 ({ ctx, customerId, entityId, deductions, + overageBehaviour = "cap", + addToAdjustment = false, }: DeductionTxParams) => { const { db, org, env } = ctx; - - const customer = await CusService.getFull({ + const fullCus = await CusService.getFull({ db, idOrInternalId: customerId, orgId: org.id, @@ -52,149 +58,207 @@ const deductFromCusEnts = async ({ withSubs: true, }); - const cusEnts = cusProductsToCusEnts({ - cusProducts: customer.customer_products, - featureIds: deductions.map((d) => d.feature.id), - reverseOrder: org.config?.reverse_deduction_order, - }); + const printLogs = false; - const cusPrices = cusProductsToPrices({ - cusProducts: cusEnts.map((cusEnt) => cusEnt.customer_product), - }); + 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; - 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, + const relevantFeatures = getRelevantFeatures({ features: ctx.features, - db, - feature, - cusEnts: originalCusEnts, - newCusEnts: cusEnts, - fullCus: customer, - logger: ctx.logger, + featureId: feature.id, }); - // Insert event into database - return customer; + const cusEnts = cusProductsToCusEnts({ + cusProducts: fullCus.customer_products, + featureIds: relevantFeatures.map((f) => f.id), + reverseOrder: org.config?.reverse_deduction_order, + }); + + const { unlimited } = getUnlimitedAndUsageAllowed({ + cusEnts, + internalFeatureId: feature.internal_id!, + }); + + if (cusEnts.length === 0 || unlimited) continue; + + const cusEntInput = cusEnts.map((ce) => { + const creditCost = getCreditCost({ + featureId: feature.id, + creditSystem: ce.entitlement.feature, + }); + + 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", + }); + } + + const { updates, remaining } = resultJson; + + // Check if deduction was rejected due to limits + 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, + env, + org, + cusPrices: cusPrices as any, + customer: fullCus, + affectedFeature: feature, + cusEnt: cusEnt as any, + originalBalance: originalGrpBalance, + newBalance: newGrpBalance, + logger: ctx.logger, + }); + + updateCusEntInFullCus({ + 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 { db, org, env, logger } = ctx; + const { db, org, env } = ctx; + + let fullCus: FullCustomer | undefined; + let event: Event | undefined; await db.transaction( async (tx) => { - // 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 - const lockKeyStr = `${params.customerId}_${org.id}_${env}${params.entityId ? `_${params.entityId}` : ""}`; + // Pass tx as the db connection + const txParams = { + ...params, + ctx: { + ...ctx, + db: tx as unknown as typeof db, + }, + }; - const hash = - lockKeyStr.split("").reduce((acc, char) => { - return (acc << 5) - acc + char.charCodeAt(0); - }, 0) | 0; // Convert to 32-bit integer + fullCus = await deductFromCusEnts(txParams); - logger.info(`Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`); - - // 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 (!fullCus) return; if (params.eventInfo) { const newEvent = await constructEvent({ - ctx, + ctx: txParams.ctx, eventInfo: params.eventInfo, - fullCus: customer, + fullCus, }); - await EventService.insert({ + event = await EventService.insert({ db: tx as unknown as DrizzleCli, 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", @@ -208,4 +272,107 @@ export const runDeductionTx = async (params: DeductionTxParams) => { org, 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; +// } diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts index 6cbe487d9..9f827dc7c 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts @@ -57,18 +57,11 @@ export const getCusEntMasterBalance = ({ // Get unused count - const unusedCount = - entities && - entities.filter( - (entity) => - entity.internal_feature_id == feature.internal_id && entity.deleted, - ).length; - return { balance: cusEnt.balance, adjustment: cusEnt.adjustment, count: 1, - unused: unusedCount, + unused: cusEnt.replaceables?.length || 0, }; }; @@ -115,9 +108,9 @@ export const getRelatedCusPrice = ( ) => { return cusPrices.find((cusPrice) => { 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; }); diff --git a/server/src/internal/features/creditSystemUtils.ts b/server/src/internal/features/creditSystemUtils.ts index fab628837..a1b1d550e 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -73,12 +73,15 @@ export const featureToCreditSystem = ({ export const getCreditCost = ({ featureId, creditSystem, - amount, + amount = 1, }: { featureId: string; creditSystem: Feature; - amount: number; + amount?: number; }) => { + if (creditSystem.type !== FeatureType.CreditSystem) { + return amount; + } const schema: CreditSchemaItem[] = creditSystem.config.schema; for (const schemaItem of schema) { diff --git a/server/src/internal/features/utils/constructFeatureUtils.ts b/server/src/internal/features/utils/constructFeatureUtils.ts index 5794eb555..73f59df0e 100644 --- a/server/src/internal/features/utils/constructFeatureUtils.ts +++ b/server/src/internal/features/utils/constructFeatureUtils.ts @@ -75,12 +75,14 @@ export const constructMeteredFeature = ({ orgId, env, usageType, + eventNames = [], }: { featureId: string; name?: string; orgId: string; env: AppEnv; usageType: FeatureUsageType; + eventNames?: string[]; }) => { const newFeature: Feature = { internal_id: generateId("fe"), @@ -106,7 +108,7 @@ export const constructMeteredFeature = ({ usage_type: usageType, }, archived: false, - event_names: [], + event_names: eventNames, }; return newFeature; diff --git a/server/src/test.ts b/server/src/test.ts new file mode 100644 index 000000000..70f2f9961 --- /dev/null +++ b/server/src/test.ts @@ -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)); diff --git a/server/src/trigger/adjustAllowance.ts b/server/src/trigger/adjustAllowance.ts index 1ed281370..fc51490aa 100644 --- a/server/src/trigger/adjustAllowance.ts +++ b/server/src/trigger/adjustAllowance.ts @@ -96,7 +96,7 @@ export const adjustAllowance = async ({ !cusProduct || !cusPrice || billingType !== BillingType.InArrearProrated || - originalBalance == newBalance + originalBalance === newBalance ) { return { newReplaceables: [], invoice: null, deletedReplaceables: null }; } diff --git a/server/src/trigger/updateUsageTask.ts b/server/src/trigger/updateUsageTask.ts index af36b1aee..0037677e7 100644 --- a/server/src/trigger/updateUsageTask.ts +++ b/server/src/trigger/updateUsageTask.ts @@ -3,6 +3,8 @@ import { type AppEnv, CusProductStatus, type Customer, + customerEntitlements, + customers, ErrCode, type Feature, FeatureType, @@ -521,21 +523,31 @@ export const runUpdateUsageTask = async ({ const cusEnts = await db.transaction( 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 // Include entity_id in lock key so different entities can update concurrently - const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`; - const hash = - lockKeyStr.split("").reduce((acc, char) => { - return (acc << 5) - acc + char.charCodeAt(0); - }, 0) | 0; // Convert to 32-bit integer + // const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`; + // const hash = + // lockKeyStr.split("").reduce((acc, char) => { + // return (acc << 5) - acc + char.charCodeAt(0); + // }, 0) | 0; // Convert to 32-bit integer - console.log( - ` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`, - ); - await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`); - console.log( - ` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`, - ); + // console.log( + // ` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`, + // ); + // await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`); + // console.log( + // ` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`, + // ); return await updateUsage({ db: tx as unknown as DrizzleCli, diff --git a/server/test.ts b/server/test.ts deleted file mode 100644 index 08c12af00..000000000 --- a/server/test.ts +++ /dev/null @@ -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)); diff --git a/server/tests/_guides/general-test-guide.md b/server/tests/_guides/general-test-guide.md index 370b8f4ce..511ab02a7 100644 --- a/server/tests/_guides/general-test-guide.md +++ b/server/tests/_guides/general-test-guide.md @@ -49,18 +49,51 @@ const balance = customer.features[TestFeature.Messages].balance; 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 import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; +// ✅ GOOD - Use expectAutumnError await expectAutumnError({ errCode: ErrCode.CustomerNotFound, func: async () => { 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 keys can only access: @@ -80,6 +113,38 @@ Public keys CANNOT: - `test` - Individual test cases - 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 ```typescript diff --git a/server/tests/_guides/track-endpoint-tests.md b/server/tests/_guides/track-endpoint-tests.md new file mode 100644 index 000000000..141c0c1ff --- /dev/null +++ b/server/tests/_guides/track-endpoint-tests.md @@ -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 +``` + diff --git a/server/tests/advanced/rollovers/rollover1.ts b/server/tests/advanced/rollovers/rollover1.ts index 9b81e8ecb..edbc7d87a 100644 --- a/server/tests/advanced/rollovers/rollover1.ts +++ b/server/tests/advanced/rollovers/rollover1.ts @@ -176,6 +176,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` expect(rollover1.balance).to.equal(0); expect(rollover2.balance).to.equal(350); }); + return; it("should track and deduct from rollover + original balance", async () => { await autumn.track({ diff --git a/server/tests/advanced/rollovers/rollover2.ts b/server/tests/advanced/rollovers/rollover2.ts index bdd212140..0b8a6cd30 100644 --- a/server/tests/advanced/rollovers/rollover2.ts +++ b/server/tests/advanced/rollovers/rollover2.ts @@ -147,7 +147,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item await resetAndGetCusEnt({ db, customer, - productGroup: free.group, + productGroup: free.group!, featureId: TestFeature.Messages, }); @@ -166,7 +166,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item await resetAndGetCusEnt({ db, customer, - productGroup: free.group, + productGroup: free.group!, featureId: TestFeature.Messages, }); diff --git a/server/tests/attach/upgrade/upgrade6.test.ts b/server/tests/attach/upgrade/upgrade6.test.ts index 55c8ad3c9..a11e36415 100644 --- a/server/tests/attach/upgrade/upgrade6.test.ts +++ b/server/tests/attach/upgrade/upgrade6.test.ts @@ -95,7 +95,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => feature_id: TestFeature.Words, value: usage, }); - await timeout(4000); const cus = await CusService.get({ db, diff --git a/server/tests/balances/track/basic/track-basic1.test.ts b/server/tests/balances/track/basic/track-basic1.test.ts new file mode 100644 index 000000000..6ac3dc6d6 --- /dev/null +++ b/server/tests/balances/track/basic/track-basic1.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic2.test.ts b/server/tests/balances/track/basic/track-basic2.test.ts new file mode 100644 index 000000000..953f83d05 --- /dev/null +++ b/server/tests/balances/track/basic/track-basic2.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic3.test.ts b/server/tests/balances/track/basic/track-basic3.test.ts new file mode 100644 index 000000000..927ac5577 --- /dev/null +++ b/server/tests/balances/track/basic/track-basic3.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic4.test.ts b/server/tests/balances/track/basic/track-basic4.test.ts new file mode 100644 index 000000000..2101bdedc --- /dev/null +++ b/server/tests/balances/track/basic/track-basic4.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic5.test.ts b/server/tests/balances/track/basic/track-basic5.test.ts new file mode 100644 index 000000000..b495d5fef --- /dev/null +++ b/server/tests/balances/track/basic/track-basic5.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic6.test.ts b/server/tests/balances/track/basic/track-basic6.test.ts new file mode 100644 index 000000000..528e40348 --- /dev/null +++ b/server/tests/balances/track/basic/track-basic6.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic7.test.ts b/server/tests/balances/track/basic/track-basic7.test.ts new file mode 100644 index 000000000..0af3c1f53 --- /dev/null +++ b/server/tests/balances/track/basic/track-basic7.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic8.test.ts b/server/tests/balances/track/basic/track-basic8.test.ts new file mode 100644 index 000000000..fb441ffe3 --- /dev/null +++ b/server/tests/balances/track/basic/track-basic8.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/concurrency/concurrent-track1.test.ts b/server/tests/balances/track/concurrency/concurrent-track1.test.ts new file mode 100644 index 000000000..58ed24585 --- /dev/null +++ b/server/tests/balances/track/concurrency/concurrent-track1.test.ts @@ -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) + }); +}); diff --git a/server/tests/balances/track/concurrency/concurrent-track2.test.ts b/server/tests/balances/track/concurrency/concurrent-track2.test.ts new file mode 100644 index 000000000..688afcfdf --- /dev/null +++ b/server/tests/balances/track/concurrency/concurrent-track2.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/concurrency/concurrent-track3.test.ts b/server/tests/balances/track/concurrency/concurrent-track3.test.ts new file mode 100644 index 000000000..50a623559 --- /dev/null +++ b/server/tests/balances/track/concurrency/concurrent-track3.test.ts @@ -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); +// }); +// }); diff --git a/server/tests/balances/track/concurrency/concurrent-track4.test.ts b/server/tests/balances/track/concurrency/concurrent-track4.test.ts new file mode 100644 index 000000000..f821b0b2d --- /dev/null +++ b/server/tests/balances/track/concurrency/concurrent-track4.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/concurrency/concurrent-track5.test.ts b/server/tests/balances/track/concurrency/concurrent-track5.test.ts new file mode 100644 index 000000000..4c6b79536 --- /dev/null +++ b/server/tests/balances/track/concurrency/concurrent-track5.test.ts @@ -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); + } + }); +}); diff --git a/server/tests/balances/track/credit-systems/track-credit-system1.test.ts b/server/tests/balances/track/credit-systems/track-credit-system1.test.ts new file mode 100644 index 000000000..ddda4788a --- /dev/null +++ b/server/tests/balances/track/credit-systems/track-credit-system1.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/credit-systems/track-credit-system2.test.ts b/server/tests/balances/track/credit-systems/track-credit-system2.test.ts new file mode 100644 index 000000000..eefddfee1 --- /dev/null +++ b/server/tests/balances/track/credit-systems/track-credit-system2.test.ts @@ -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(), + ); + }); +}); diff --git a/server/tests/balances/track/credit-systems/track-credit-system3.test.ts b/server/tests/balances/track/credit-systems/track-credit-system3.test.ts new file mode 100644 index 000000000..bbe2cc680 --- /dev/null +++ b/server/tests/balances/track/credit-systems/track-credit-system3.test.ts @@ -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(), + ); + }); +}); diff --git a/server/tests/balances/track/credit-systems/track-credit-system4.test.ts b/server/tests/balances/track/credit-systems/track-credit-system4.test.ts new file mode 100644 index 000000000..29e8b892a --- /dev/null +++ b/server/tests/balances/track/credit-systems/track-credit-system4.test.ts @@ -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), + ); + }); +}); diff --git a/server/tests/balances/track/legacy/track-legacy1.test.ts b/server/tests/balances/track/legacy/track-legacy1.test.ts new file mode 100644 index 000000000..1b007e4b6 --- /dev/null +++ b/server/tests/balances/track/legacy/track-legacy1.test.ts @@ -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); + }); +}); diff --git a/server/tests/balances/track/misc/trackMisc1.test.ts b/server/tests/balances/track/misc/trackMisc1.test.ts deleted file mode 100644 index 67ef0376b..000000000 --- a/server/tests/balances/track/misc/trackMisc1.test.ts +++ /dev/null @@ -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}`, - ); - }); -}); diff --git a/server/tests/balances/track/misc/trackMisc2.test.ts b/server/tests/balances/track/misc/trackMisc2.test.ts deleted file mode 100644 index e5b0edb3f..000000000 --- a/server/tests/balances/track/misc/trackMisc2.test.ts +++ /dev/null @@ -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}`, - ); - }); -}); diff --git a/server/tests/balances/track/misc/trackMisc3.test.ts b/server/tests/balances/track/misc/trackMisc3.test.ts deleted file mode 100644 index 1e51c1267..000000000 --- a/server/tests/balances/track/misc/trackMisc3.test.ts +++ /dev/null @@ -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}`, - ); - }); -}); diff --git a/server/tests/balances/track/misc/trackMisc4.test.ts b/server/tests/balances/track/misc/trackMisc4.test.ts deleted file mode 100644 index a9deb43a2..000000000 --- a/server/tests/balances/track/misc/trackMisc4.test.ts +++ /dev/null @@ -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}`, - // ); - }); -}); diff --git a/server/tests/balances/track/misc/trackMisc5.test.ts b/server/tests/balances/track/misc/trackMisc5.test.ts deleted file mode 100644 index 35b16a598..000000000 --- a/server/tests/balances/track/misc/trackMisc5.test.ts +++ /dev/null @@ -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}`, - ); - } - }); -}); diff --git a/server/tests/balances/track/misc/trackMisc6.test.ts b/server/tests/balances/track/misc/trackMisc6.test.ts deleted file mode 100644 index 8b85e7d53..000000000 --- a/server/tests/balances/track/misc/trackMisc6.test.ts +++ /dev/null @@ -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)`); - } - }); -}); diff --git a/server/tests/balances/track/misc/trackMisc7.test.ts b/server/tests/balances/track/misc/trackMisc7.test.ts deleted file mode 100644 index 9720d8560..000000000 --- a/server/tests/balances/track/misc/trackMisc7.test.ts +++ /dev/null @@ -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}`); - }); -}); diff --git a/server/tests/balances/track/trackTestUtils.ts b/server/tests/balances/track/trackTestUtils.ts new file mode 100644 index 000000000..f3f7d521d --- /dev/null +++ b/server/tests/balances/track/trackTestUtils.ts @@ -0,0 +1,5 @@ +import { SuccessCode, type TrackResponse } from "@autumn/shared"; + +export const trackWasSuccessful = ({ res }: { res: TrackResponse }) => { + return res.code === SuccessCode.EventReceived; +}; diff --git a/server/tests/setup/v2Features.ts b/server/tests/setup/v2Features.ts index 5582a258a..228dca478 100644 --- a/server/tests/setup/v2Features.ts +++ b/server/tests/setup/v2Features.ts @@ -20,6 +20,9 @@ export enum TestFeature { Action1 = "action1", // single use (pay per use) Action2 = "action2", // single use (pay per use) Credits = "credits", // credit system + + Action3 = "action3", // single use (pay per use) + Credits2 = "credits2", // credit system } const orgId = process.env.TESTS_ORG_ID!; @@ -64,12 +67,21 @@ export const features = { orgId, env: AppEnv.Sandbox, usageType: FeatureUsageType.Single, + eventNames: ["action-event"], }), [TestFeature.Action2]: constructMeteredFeature({ featureId: TestFeature.Action2, orgId, env: AppEnv.Sandbox, 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({ 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, + }, + ], + }), }; diff --git a/shared/api/balances/trackModels.ts b/shared/api/balances/trackModels.ts index b47a7767a..97431d679 100644 --- a/shared/api/balances/trackModels.ts +++ b/shared/api/balances/trackModels.ts @@ -60,6 +60,9 @@ export const TrackParamsSchema = z entity_data: EntityDataSchema.optional().meta({ 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( (data) => { @@ -78,7 +81,7 @@ export const TrackParamsSchema = z }, ); -export const TrackResultSchema = z.object({ +export const TrackResponseSchema = z.object({ id: z.string().meta({ description: "The ID of the created event", }), @@ -100,3 +103,4 @@ export const TrackResultSchema = z.object({ }); export type TrackParams = z.infer; +export type TrackResponse = z.infer; diff --git a/shared/api/balances/usageModels.ts b/shared/api/balances/usageModels.ts index 108c03c23..5be6cfbc5 100644 --- a/shared/api/balances/usageModels.ts +++ b/shared/api/balances/usageModels.ts @@ -20,3 +20,5 @@ export const SetUsageParamsSchema = z.object({ customer_data: CustomerDataSchema.optional(), }); + +export type SetUsageParams = z.infer; diff --git a/shared/api/errors/classes/balancesErrClasses.ts b/shared/api/errors/classes/balancesErrClasses.ts new file mode 100644 index 000000000..8b26acc7d --- /dev/null +++ b/shared/api/errors/classes/balancesErrClasses.ts @@ -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"; + } +} diff --git a/shared/api/errors/codes/balancesErrCodes.ts b/shared/api/errors/codes/balancesErrCodes.ts new file mode 100644 index 000000000..2a05243d0 --- /dev/null +++ b/shared/api/errors/codes/balancesErrCodes.ts @@ -0,0 +1,6 @@ +export const BalancesErrorCode = { + InsufficientBalance: "insufficient_balance", +} as const; + +export type BalancesErrorCode = + (typeof BalancesErrorCode)[keyof typeof BalancesErrorCode]; diff --git a/shared/api/errors/index.ts b/shared/api/errors/index.ts index d8cd5c635..d64331267 100644 --- a/shared/api/errors/index.ts +++ b/shared/api/errors/index.ts @@ -1,8 +1,10 @@ export * from "./base/InternalError.js"; export * from "./base/RecaseError.js"; +export * from "./classes/balancesErrClasses.js"; export * from "./classes/cusErrClasses.js"; export * from "./classes/cusProductErrClasses.js"; export * from "./classes/productErrClasses.js"; +export * from "./codes/balancesErrCodes.js"; export * from "./codes/cusErrCodes.js"; export * from "./codes/cusProductErrCodes.js"; export * from "./codes/productErrCodes.js"; diff --git a/shared/api/models.ts b/shared/api/models.ts index 114d190ee..7ad02f58e 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -61,6 +61,7 @@ export * from "./referrals/referralsOpenApi.js"; // Balances export * from "./balances/check/previousVersions/CheckResponseV0.js"; export * from "./balances/trackModels.js"; +export * from "./balances/usageModels.js"; // Errors export * from "./errors/index.js"; // Models diff --git a/shared/enums/SuccessCode.ts b/shared/enums/SuccessCode.ts index 75d4d1a46..6e6dd9396 100644 --- a/shared/enums/SuccessCode.ts +++ b/shared/enums/SuccessCode.ts @@ -1,5 +1,6 @@ export enum SuccessCode { - // Events + // Track + SuccessfullyDeducted = "successfully_deducted", EventReceived = "event_received", EventReceivedCustomerCreated = "event_received_customer_created", diff --git a/shared/utils/cusEntUtils/balanceUtils.ts b/shared/utils/cusEntUtils/balanceUtils.ts index 30c51ebad..abd453c1a 100644 --- a/shared/utils/cusEntUtils/balanceUtils.ts +++ b/shared/utils/cusEntUtils/balanceUtils.ts @@ -1,4 +1,6 @@ +import { Decimal } from "decimal.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"; export const getSummedEntityBalances = ({ @@ -67,3 +69,22 @@ export const getCusEntBalance = ({ 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; +}; diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index 319a02725..1e6ab9633 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -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 = ({ cusEnt, @@ -7,3 +10,37 @@ export const formatCusEnt = ({ }) => { 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 | 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, + }; + } + } + } +}; diff --git a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts index 1f04975f6..64c99a7aa 100644 --- a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts +++ b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts @@ -1,5 +1,5 @@ -import { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; -import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import { FeatureType } from "../../models/featureModels/featureEnums.js"; import { AllowanceType } from "../../models/productModels/entModels/entModels.js"; import { entIntervalToValue } from "../intervalUtils.js"; @@ -15,41 +15,41 @@ export const sortCusEntsForDeduction = ( const bEnt = b.entitlement; // 1. If boolean, go first - if (aEnt.feature.type == FeatureType.Boolean) { + if (aEnt.feature.type === FeatureType.Boolean) { return -1; } - if (bEnt.feature.type == FeatureType.Boolean) { + if (bEnt.feature.type === FeatureType.Boolean) { return 1; } // 1. If a is credit system and b is not, a should go last if ( - aEnt.feature.type == FeatureType.CreditSystem && - bEnt.feature.type != FeatureType.CreditSystem + aEnt.feature.type === FeatureType.CreditSystem && + bEnt.feature.type !== FeatureType.CreditSystem ) { return 1; } // 2. If a is not credit system and b is, a should go first if ( - aEnt.feature.type != FeatureType.CreditSystem && - bEnt.feature.type == FeatureType.CreditSystem + aEnt.feature.type !== FeatureType.CreditSystem && + bEnt.feature.type === FeatureType.CreditSystem ) { return -1; } // 2. Sort by unlimited (unlimited goes first) if ( - aEnt.allowance_type == AllowanceType.Unlimited && - bEnt.allowance_type != AllowanceType.Unlimited + aEnt.allowance_type === AllowanceType.Unlimited && + bEnt.allowance_type !== AllowanceType.Unlimited ) { return -1; } if ( - aEnt.allowance_type != AllowanceType.Unlimited && - bEnt.allowance_type == AllowanceType.Unlimited + aEnt.allowance_type !== AllowanceType.Unlimited && + bEnt.allowance_type === AllowanceType.Unlimited ) { return 1; } @@ -64,7 +64,7 @@ export const sortCusEntsForDeduction = ( } // 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) { return nextResetFirst; @@ -76,8 +76,8 @@ export const sortCusEntsForDeduction = ( } // 3. Sort by interval - let aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count); - let bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count); + const aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count); + const bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count); if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) { if (reverseOrder) { return bVal.sub(aVal).toNumber(); @@ -89,8 +89,8 @@ export const sortCusEntsForDeduction = ( } // Check if a is main product - let aIsAddOn = a.customer_product?.product?.is_add_on; - let bIsAddOn = b.customer_product?.product?.is_add_on; + const aIsAddOn = a.customer_product?.product?.is_add_on; + const bIsAddOn = b.customer_product?.product?.is_add_on; if (aIsAddOn && !bIsAddOn) { return 1; diff --git a/shared/utils/featureUtils.ts b/shared/utils/featureUtils.ts index 35d02758b..9b23f5aa3 100644 --- a/shared/utils/featureUtils.ts +++ b/shared/utils/featureUtils.ts @@ -2,6 +2,7 @@ import { ApiFeatureSchema } from "@api/features/apiFeature.js"; import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js"; import { FeatureType } from "../models/featureModels/featureEnums.js"; import type { Feature } from "../models/featureModels/featureModels.js"; +import { creditSystemContainsFeature } from "./featureUtils/creditSystemUtils.js"; // import { // constructBooleanFeature, // constructCreditSystem, @@ -35,3 +36,20 @@ export const toApiFeature = ({ feature }: { feature: Feature }) => { credit_schema: creditSchema, }); }; + +export const getRelevantFeatures = ({ + features, + featureId, +}: { + features: Feature[]; + featureId: string; +}) => { + return features.filter( + (f) => + f.id === featureId || + creditSystemContainsFeature({ + creditSystem: f, + meteredFeatureId: featureId, + }), + ); +}; diff --git a/shared/utils/featureUtils/creditSystemUtils.ts b/shared/utils/featureUtils/creditSystemUtils.ts new file mode 100644 index 000000000..47c0095a8 --- /dev/null +++ b/shared/utils/featureUtils/creditSystemUtils.ts @@ -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; +};