diff --git a/server/src/_luaScriptsV2/customerEntitlements/adjustCustomerEntitlementBalance.lua b/server/src/_luaScriptsV2/customerEntitlements/adjustCustomerEntitlementBalance.lua new file mode 100644 index 000000000..ba36d11f0 --- /dev/null +++ b/server/src/_luaScriptsV2/customerEntitlements/adjustCustomerEntitlementBalance.lua @@ -0,0 +1,55 @@ +-- ============================================================================ +-- INCREMENT CUSTOMER ENTITLEMENT BALANCE +-- Atomically increments a cusEnt's balance in the cached FullCustomer JSON +-- using JSON.NUMINCRBY (relative delta, safe with concurrent deductions). +-- ============================================================================ +-- KEYS[1] = fullCustomer cache key +-- ARGV[1] = JSON: { cus_ent_id: string, delta: number } +-- Returns: JSON: { ok: true, new_balance: number } | { ok: false, error: string } +-- ============================================================================ + +local cache_key = KEYS[1] +local params = cjson.decode(ARGV[1]) + +local cus_ent_id = params.cus_ent_id +local delta = tonumber(params.delta) + +if not cus_ent_id or not delta then + return cjson.encode({ ok = false, error = "missing cus_ent_id or delta" }) +end + +-- Read the full customer to find the entitlement indices +local raw = redis.call('JSON.GET', cache_key, '.') +if not raw then + return cjson.encode({ ok = false, error = "cache_miss" }) +end + +local full_customer = cjson.decode(raw) +local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(full_customer, cus_ent_id) + +if not cus_ent then + return cjson.encode({ ok = false, error = "cus_ent_not_found" }) +end + +-- Build the JSON path to the balance field +local base_path +if cp_idx then + -- Lua arrays are 1-indexed, RedisJSON is 0-indexed + base_path = '$.customer_products[' .. (cp_idx - 1) .. '].customer_entitlements[' .. (ce_idx - 1) .. ']' +else + base_path = '$.extra_customer_entitlements[' .. (ce_idx - 1) .. ']' +end + +local balance_path = base_path .. '.balance' + +-- Atomic relative increment (JSON.NUMINCRBY with $ path returns a JSON array e.g. "[105]") +local result = redis.call('JSON.NUMINCRBY', cache_key, balance_path, delta) +local new_balance = cjson.decode(result)[1] + +-- Bump cache_version so sync_balances_v2 conflict detection stays in sync +-- with CusEntService.increment (which bumps Postgres cache_version atomically). +local version_path = base_path .. '.cache_version' +local version_result = redis.call('JSON.NUMINCRBY', cache_key, version_path, 1) +local new_cache_version = cjson.decode(version_result)[1] + +return cjson.encode({ ok = true, new_balance = new_balance, new_cache_version = new_cache_version }) diff --git a/server/src/_luaScriptsV2/customerProducts/updateCustomerProduct.lua b/server/src/_luaScriptsV2/customerProducts/updateCustomerProduct.lua new file mode 100644 index 000000000..f16016fbb --- /dev/null +++ b/server/src/_luaScriptsV2/customerProducts/updateCustomerProduct.lua @@ -0,0 +1,76 @@ +--[[ + Lua Script: Update Customer Product Fields in Cache + + Atomically updates specific fields on a cusProduct in the cached + FullCustomer. Matches by cusProduct id, then applies targeted + JSON.SET on each provided field. + + CRDT Safety Note (Active-Active Redis): + - JSON.SET on specific paths follows "Update versus update" conflict + resolution (smallest instance ID wins) — safe for field-level updates. + - We never overwrite the entire cusProduct object. + + KEYS[1] = FullCustomer cache key + + ARGV[1] = JSON: { cus_product_id: string, updates: { field: value, ... } } + Supported fields: status, canceled, canceled_at, ended_at, + subscription_ids, scheduled_ids, options, quantity, + entity_id, internal_entity_id, trial_ends_at, collection_method + + Returns JSON: + { "ok": true, "updated_count": number } + { "ok": false, "error": string } +]] + +local cache_key = KEYS[1] +local params = cjson.decode(ARGV[1]) + +local cus_product_id = params.cus_product_id +local updates = params.updates + +if not cus_product_id then + return cjson.encode({ ok = false, error = "missing cus_product_id" }) +end + +if not updates then + return cjson.encode({ ok = false, error = "missing updates" }) +end + +-- Read the full customer to find the matching cusProduct index +local raw = redis.call('JSON.GET', cache_key, '.') +if not raw then + return cjson.encode({ ok = false, error = "cache_miss" }) +end + +local full_customer = cjson.decode(raw) + +if not full_customer.customer_products then + return cjson.encode({ ok = false, error = "no_customer_products" }) +end + +-- Find cusProduct by id. Returns (cus_product, 0-indexed position) or (nil, nil). +local function find_customer_product(customer_products, id) + for idx, cp in ipairs(customer_products) do + if cp.id == id then + return cp, idx - 1 + end + end + return nil, nil +end + +local _, cp_idx = find_customer_product(full_customer.customer_products, cus_product_id) + +if cp_idx == nil then + return cjson.encode({ ok = false, error = "cus_product_not_found" }) +end + +local base_path = '$.customer_products[' .. cp_idx .. '].' +local updated_count = 0 + +-- Apply each update field via targeted JSON.SET +for field, value in pairs(updates) do + redis.call('JSON.SET', cache_key, base_path .. field, cjson.encode(value)) + updated_count = updated_count + 1 +end + +return cjson.encode({ ok = true, updated_count = updated_count }) diff --git a/server/src/_luaScriptsV2/appendEntityToCustomer.lua b/server/src/_luaScriptsV2/customers/appendEntityToCustomer.lua similarity index 100% rename from server/src/_luaScriptsV2/appendEntityToCustomer.lua rename to server/src/_luaScriptsV2/customers/appendEntityToCustomer.lua diff --git a/server/src/_luaScriptsV2/updateCustomerData.lua b/server/src/_luaScriptsV2/customers/updateCustomerData.lua similarity index 88% rename from server/src/_luaScriptsV2/updateCustomerData.lua rename to server/src/_luaScriptsV2/customers/updateCustomerData.lua index dbdcda9fd..0b25c68da 100644 --- a/server/src/_luaScriptsV2/updateCustomerData.lua +++ b/server/src/_luaScriptsV2/customers/updateCustomerData.lua @@ -15,7 +15,8 @@ metadata?: object | null, send_email_receipts?: boolean | null, processor?: object | null, - processors?: object | null + processors?: object | null, + auto_topups?: array | null } } @@ -91,4 +92,13 @@ if updates.processors ~= nil and updates.processors ~= cjson.null then table.insert(updated_fields, 'processors') end +if updates.auto_topups ~= nil then + if updates.auto_topups == cjson.null then + redis.call('JSON.SET', cache_key, '$.auto_topups', 'null') + else + redis.call('JSON.SET', cache_key, '$.auto_topups', cjson.encode(updates.auto_topups)) + end + table.insert(updated_fields, 'auto_topups') +end + return cjson.encode({ success = true, updated_fields = updated_fields }) diff --git a/server/src/_luaScriptsV2/customers/upsertInvoice.lua b/server/src/_luaScriptsV2/customers/upsertInvoice.lua new file mode 100644 index 000000000..7c80d0b0a --- /dev/null +++ b/server/src/_luaScriptsV2/customers/upsertInvoice.lua @@ -0,0 +1,63 @@ +--[[ + Lua Script: Upsert Invoice in Customer Cache + + Atomically upserts an invoice in the customer's invoices array: + - If invoice with same stripe_id exists: replace it via JSON.SET + - Otherwise: append via JSON.ARRAPPEND + + CRDT Safety Note (Active-Active Redis): + - JSON.ARRAPPEND follows "Update versus update array" conflict resolution + - Resolution type: Merge - results from all instances are merged + - JSON.SET on specific paths follows "Update versus update" (smallest instance ID wins) + + KEYS[1] = FullCustomer cache key + + ARGV[1] = JSON-encoded invoice object (must have stripe_id field) + + Returns JSON: + { "success": true, "action": "appended" } + { "success": true, "action": "updated" } + { "success": false, "cache_miss": true } +]] + +local cache_key = KEYS[1] +local invoice_json = ARGV[1] + +-- Check if cache exists +local key_exists = redis.call('EXISTS', cache_key) +if key_exists == 0 then + return cjson.encode({ success = false, cache_miss = true }) +end + +-- Parse the incoming invoice to get stripe_id for matching +local invoice = cjson.decode(invoice_json) +local stripe_id = invoice.stripe_id + +-- Get current invoices array +local invoices_json = redis.call('JSON.GET', cache_key, '$.invoices') +if not invoices_json then + -- invoices array doesn't exist, create it with the new invoice + redis.call('JSON.SET', cache_key, '$.invoices', cjson.encode({invoice})) + return cjson.encode({ success = true, action = "appended" }) +end + +-- Parse invoices array (JSON.GET with JSONPath returns array of results) +local invoices_wrapper = cjson.decode(invoices_json) +local invoices = invoices_wrapper[1] or {} + +-- Search for existing invoice by stripe_id +if stripe_id then + for idx, existing_invoice in ipairs(invoices) do + if existing_invoice.stripe_id == stripe_id then + -- Replace the entire invoice at this index + local array_idx = idx - 1 -- JSON arrays are 0-indexed + redis.call('JSON.SET', cache_key, '$.invoices[' .. array_idx .. ']', invoice_json) + return cjson.encode({ success = true, action = "updated" }) + end + end +end + +-- No existing invoice found, append +redis.call('JSON.ARRAPPEND', cache_key, '$.invoices', invoice_json) + +return cjson.encode({ success = true, action = "appended" }) diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index 467ac1771..c0fe36da4 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -129,30 +129,65 @@ export const UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS} ${updateMainScript}`; // ============================================================================ -// UPDATE CUSTOMER DATA SCRIPT (top-level customer fields) +// ADJUST CUSTOMER ENTITLEMENT BALANCE SCRIPT // ============================================================================ +const CUS_ENT_DIR = join(__dirname, "customerEntitlements"); + +const adjustBalanceMainScript = readFileSync( + join(CUS_ENT_DIR, "adjustCustomerEntitlementBalance.lua"), + "utf-8", +); + /** - * Lua script for atomically updating top-level customer fields in the cached - * FullCustomer (name, email, metadata, send_email_receipts, etc.). + * Lua script for atomically incrementing a cusEnt balance in the cached + * FullCustomer via JSON.NUMINCRBY. Safe with concurrent deductions. */ +export const ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT = `${LUA_UTILS} +${adjustBalanceMainScript}`; + +// ============================================================================ +// CUSTOMER SCRIPTS (top-level customer fields, entities, invoices) +// ============================================================================ + +const CUSTOMER_DIR = join(__dirname, "customers"); + +/** Atomically update top-level customer fields (name, email, metadata, etc.). */ export const UPDATE_CUSTOMER_DATA_SCRIPT = readFileSync( - join(__dirname, "updateCustomerData.lua"), + join(CUSTOMER_DIR, "updateCustomerData.lua"), + "utf-8", +); + +/** + * Atomically append an entity to the customer's entities array. + * CRDT-safe: JSON.ARRAPPEND uses merge conflict resolution in Active-Active. + */ +export const APPEND_ENTITY_TO_CUSTOMER_SCRIPT = readFileSync( + join(CUSTOMER_DIR, "appendEntityToCustomer.lua"), + "utf-8", +); + +/** + * Atomically upsert an invoice in the customer's invoices array. + * Matches by stripe_id — replaces if found, appends if not. + * CRDT-safe: JSON.ARRAPPEND uses merge, JSON.SET uses update-vs-update. + */ +export const UPSERT_INVOICE_IN_CUSTOMER_SCRIPT = readFileSync( + join(CUSTOMER_DIR, "upsertInvoice.lua"), "utf-8", ); // ============================================================================ -// APPEND ENTITY TO CUSTOMER SCRIPT +// CUSTOMER PRODUCT SCRIPTS // ============================================================================ +const CUS_PRODUCT_DIR = join(__dirname, "customerProducts"); + /** - * Lua script for atomically appending an entity to the customer's entities - * array in the cached FullCustomer. Checks for duplicates before appending. - * - * CRDT-safe: JSON.ARRAPPEND uses merge conflict resolution in Active-Active, - * so concurrent appends from different regions will both succeed. + * Atomically update specific fields on a cusProduct in the cached FullCustomer. + * CRDT-safe: JSON.SET on specific paths uses "update vs update" resolution. */ -export const APPEND_ENTITY_TO_CUSTOMER_SCRIPT = readFileSync( - join(__dirname, "appendEntityToCustomer.lua"), +export const UPDATE_CUSTOMER_PRODUCT_SCRIPT = readFileSync( + join(CUS_PRODUCT_DIR, "updateCustomerProduct.lua"), "utf-8", ); diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index cbc36e21c..472cfc07a 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -22,6 +22,7 @@ import { type CreateCustomerParamsV0Input, type CreateEntityParams, type CreateRewardProgram, + type CustomerBillingControlsInput, CustomerExpand, EntityExpand, ErrCode, @@ -509,6 +510,7 @@ export class AutumnInt { email?: string; send_email_receipts?: boolean; metadata?: Record; + billing_controls?: CustomerBillingControlsInput; }, ) => { const data = await this.patch(`/customers/${customerId}`, updates); diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index d6b64b8c7..1e40fdbda 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -14,6 +14,7 @@ import { SET_SUBSCRIPTIONS_SCRIPT, } from "../../_luaScripts/luaScripts.js"; import { + ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT, APPEND_ENTITY_TO_CUSTOMER_SCRIPT, BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT, DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, @@ -22,6 +23,8 @@ import { SET_FULL_CUSTOMER_CACHE_SCRIPT, UPDATE_CUSTOMER_DATA_SCRIPT, UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT, + UPDATE_CUSTOMER_PRODUCT_SCRIPT, + UPSERT_INVOICE_IN_CUSTOMER_SCRIPT, } from "../../_luaScriptsV2/luaScriptsV2.js"; // if (!process.env.CACHE_URL) { @@ -203,6 +206,21 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: APPEND_ENTITY_TO_CUSTOMER_SCRIPT, }); + redisInstance.defineCommand("upsertInvoiceInCustomer", { + numberOfKeys: 1, + lua: UPSERT_INVOICE_IN_CUSTOMER_SCRIPT, + }); + + redisInstance.defineCommand("adjustCustomerEntitlementBalance", { + numberOfKeys: 1, + lua: ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT, + }); + + redisInstance.defineCommand("updateCustomerProduct", { + numberOfKeys: 1, + lua: UPDATE_CUSTOMER_PRODUCT_SCRIPT, + }); + redisInstance.on("error", (error) => { console.error(`[Redis] Connection error:`, error.message); }); @@ -385,11 +403,23 @@ declare module "ioredis" { cacheKey: string, paramsJson: string, ): Promise; + adjustCustomerEntitlementBalance( + cacheKey: string, + paramsJson: string, + ): Promise; updateCustomerData(cacheKey: string, paramsJson: string): Promise; appendEntityToCustomer( cacheKey: string, entityJson: string, ): Promise; + upsertInvoiceInCustomer( + cacheKey: string, + invoiceJson: string, + ): Promise; + updateCustomerProduct( + cacheKey: string, + paramsJson: string, + ): Promise; } } diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index 255f69283..5c1ec6aea 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -5,7 +5,6 @@ import { Stripe } from "stripe"; import { handleStripeInvoicePaid } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.js"; import { handleStripeSubscriptionUpdated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.js"; import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import { handleWebhookErrorSkip } from "@/utils/routerUtils/webhookErrorSkip.js"; import { getSentryTags } from "../sentry/sentryUtils.js"; import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; @@ -52,8 +51,8 @@ export const handleStripeWebhookEvent = async ( case "invoice.updated": await handleInvoiceUpdated({ + ctx, event, - req: ctx as unknown as ExtendedRequest, }); break; diff --git a/server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts b/server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts index 2c136aba3..e3ffb7f14 100644 --- a/server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts +++ b/server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts @@ -1,20 +1,33 @@ import type { Stripe } from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { invoiceActions } from "@/internal/invoices/actions"; export const voidStripeInvoiceIfOpen = async ({ ctx, stripeInvoice, + source = "allocatedInvoice", }: { ctx: AutumnContext; stripeInvoice?: Stripe.Invoice; + source?: "autoTopup" | "allocatedInvoice"; }): Promise => { if (!stripeInvoice) return; if (stripeInvoice.status !== "open") return; - const { org, env } = ctx; + const { org, env, logger } = ctx; const stripeCli = createStripeCli({ org, env }); const voidedInvoice = await stripeCli.invoices.voidInvoice(stripeInvoice.id); + + await invoiceActions.updateFromStripe({ + ctx, + customerId: ctx.customerId ?? "", + stripeInvoice: voidedInvoice, + }); + + logger.info( + `[voidStripeInvoiceIfOpen] Voided invoice ${stripeInvoice.id} from ${source}`, + ); return voidedInvoice; }; diff --git a/server/src/external/stripe/stripeInvoiceUtils.ts b/server/src/external/stripe/stripeInvoiceUtils.ts index 7bfa01383..808304cf3 100644 --- a/server/src/external/stripe/stripeInvoiceUtils.ts +++ b/server/src/external/stripe/stripeInvoiceUtils.ts @@ -1,12 +1,9 @@ import { ErrCode, type InvoiceDiscount, - type InvoiceStatus, notNullish, stripeToAtmnAmount, } from "@autumn/shared"; -import type { DrizzleCli } from "@server/db/initDrizzle.js"; -import { InvoiceService } from "@server/internal/invoices/InvoiceService.js"; import RecaseError from "@server/utils/errorUtils.js"; import type Stripe from "stripe"; @@ -128,42 +125,6 @@ export const payForInvoice = async ({ } }; -export const updateInvoiceIfExists = async ({ - db, - invoice, -}: { - db: DrizzleCli; - invoice: Stripe.Invoice; -}) => { - // TODO: Can optimize this function... - const existingInvoice = await InvoiceService.getByStripeId({ - db, - stripeId: invoice.id!, - }); - - if (existingInvoice) { - await InvoiceService.updateByStripeId({ - db, - stripeId: invoice.id!, - updates: { - status: invoice.status as InvoiceStatus, - hosted_invoice_url: invoice.hosted_invoice_url, - total: stripeToAtmnAmount({ - amount: invoice.total, - currency: invoice.currency, - }), - discounts: getInvoiceDiscounts({ - expandedInvoice: invoice, - }), - }, - }); - - return true; - } - - return false; -}; - export const getInvoiceDiscounts = ({ expandedInvoice, }: { diff --git a/server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts b/server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts index aaa3f5349..1c3eca0c6 100644 --- a/server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts +++ b/server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts @@ -1,8 +1,8 @@ import { cp, + cusProductToProduct, deduplicateArray, type FullCusProduct, - type FullCustomerPrice, type Invoice, } from "@autumn/shared"; import type Stripe from "stripe"; @@ -11,8 +11,7 @@ import { stripeSubscriptionToScheduleId, } from "@/external/stripe/subscriptions"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; -import { InvoiceService } from "@/internal/invoices/InvoiceService"; -import { getInvoiceItems } from "@/internal/invoices/invoiceUtils"; +import { invoiceActions } from "@/internal/invoices/actions"; /** * Upserts an Autumn invoice record from a Stripe invoice webhook. @@ -41,8 +40,8 @@ export const upsertAutumnInvoice = async ({ stripeSubscription?: Stripe.Subscription; customerProducts?: FullCusProduct[]; options?: { skipNonCycleInvoices?: boolean }; -}): Promise => { - const { db, org, logger, stripeCli, fullCustomer } = ctx; +}): Promise => { + const { logger, stripeCli, fullCustomer } = ctx; // 1. Skip non-cycle invoices if requested (invoice.created uses this) if ( @@ -52,44 +51,23 @@ export const upsertAutumnInvoice = async ({ logger.info( `[upsertAutumnInvoice] Skipping non-cycle invoice (billing_reason: ${stripeInvoice.billing_reason})`, ); - return null; - } - - // 2. Try to update existing invoice first (works even without subscription) - const updated = await InvoiceService.updateFromStripeInvoice({ - db, - stripeInvoice, - }); - - if (updated) { - logger.info(`[upsertAutumnInvoice] Updated invoice ${stripeInvoice.id}`); - return updated; - } - - // 3. For creation, we need subscription context and customer products - if ( - !stripeSubscription || - !customerProducts || - customerProducts.length === 0 - ) { - logger.debug( - `[upsertAutumnInvoice] No subscription/customerProducts, skipping creation for ${stripeInvoice.id}`, - ); - return null; + return undefined; } if (!fullCustomer) { logger.warn( `[upsertAutumnInvoice] No fullCustomer, cannot create invoice ${stripeInvoice.id}`, ); - return null; + return undefined; } // 4. Get nowMs (test-clock aware) - const nowMs = await stripeSubscriptionToNowMs({ - stripeSubscription, - stripeCli, - }); + const nowMs = stripeSubscription + ? await stripeSubscriptionToNowMs({ + stripeSubscription, + stripeCli, + }) + : Date.now(); // 5. Merge scheduled-but-started customer products const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription }); @@ -99,7 +77,7 @@ export const upsertAutumnInvoice = async ({ ).filter((customerProduct) => { const { valid: hasStarted } = cp(customerProduct) .onStripeSubscription({ - stripeSubscriptionId: stripeSubscription.id, + stripeSubscriptionId: stripeSubscription?.id ?? "", }) .or.onStripeSchedule({ stripeSubscriptionScheduleId: scheduleId ?? undefined, @@ -111,50 +89,26 @@ export const upsertAutumnInvoice = async ({ }); const allCustomerProducts = [ - ...customerProducts, + ...(customerProducts ?? []), ...startedScheduledCustomerProducts, ]; - // 6. Compute product IDs and entity ID - const productIds = deduplicateArray( - allCustomerProducts.map((cp) => cp.product.id), - ); - const internalProductIds = deduplicateArray( - allCustomerProducts.map((cp) => cp.internal_product_id), - ); - const internalEntityIds = deduplicateArray( allCustomerProducts.map((cp) => cp.internal_entity_id), ); const internalEntityId = internalEntityIds.length === 1 ? internalEntityIds[0] : null; - // 7. Compute invoice items from prices - const prices = allCustomerProducts.flatMap((cp) => - cp.customer_prices.map((cpr: FullCustomerPrice) => cpr.price), - ); - - const invoiceItems = await getInvoiceItems({ - stripeInvoice, - prices, - logger, - }); - // 8. Create new invoice - const newInvoice = await InvoiceService.createInvoiceFromStripe({ - db, + const autumnInvoice = await invoiceActions.upsertFromStripe({ + ctx, stripeInvoice, - internalCustomerId: fullCustomer.internal_id, - internalEntityId, - org, - productIds, - internalProductIds, - items: invoiceItems, + fullCustomer, + fullProducts: allCustomerProducts.map((cp) => + cusProductToProduct({ cusProduct: cp }), + ), + internalEntityId: internalEntityId ?? undefined, }); - if (newInvoice) { - logger.info(`[upsertAutumnInvoice] Created invoice ${stripeInvoice.id}`); - } - - return newInvoice ?? null; + return autumnInvoice; }; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts index 573a859a9..f946492be 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts @@ -1,8 +1,4 @@ -import { - ACTIVE_STATUSES, - type FullCustomerPrice, - type InvoiceStatus, -} from "@autumn/shared"; +import { ACTIVE_STATUSES } from "@autumn/shared"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; @@ -13,14 +9,11 @@ import { import { logVercelWebhook } from "@/external/vercel/misc/vercelMiddleware.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; -import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; -import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { stripeInvoiceToStripeSubscriptionId } from "../invoices/utils/convertStripeInvoice"; import { getFullStripeInvoice, getStripeExpandedInvoice, - updateInvoiceIfExists, } from "../stripeInvoiceUtils.js"; import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; @@ -148,34 +141,5 @@ export const handleInvoiceFinalized = async ({ if (activeProducts.length === 0) { return; } - - const updated = await updateInvoiceIfExists({ - db, - invoice, - }); - - if (updated) return; - - const prices = activeProducts.flatMap((cp) => - cp.customer_prices.map((cpr: FullCustomerPrice) => cpr.price), - ); - - const invoiceItems = await getInvoiceItems({ - stripeInvoice: invoice, - prices: prices, - logger, - }); - - await InvoiceService.createInvoiceFromStripe({ - db, - stripeInvoice: expandedInvoice, - internalCustomerId: activeProducts[0].internal_customer_id, - productIds: activeProducts.map((p) => p.product.id), - internalProductIds: activeProducts.map((p) => p.internal_product_id), - internalEntityId: activeProducts[0].internal_entity_id, - status: invoice.status as InvoiceStatus, - org, - items: invoiceItems, - }); } }; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts index f1ab46b9b..be5314a58 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts @@ -5,18 +5,20 @@ import { } from "@autumn/shared"; import { Decimal } from "decimal.js"; import type Stripe from "stripe"; +import { invoiceActions } from "@/internal/invoices/actions"; import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; +import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext"; export const handleInvoiceUpdated = async ({ + ctx, event, - req, }: { + ctx: StripeWebhookContext; event: Stripe.Event; - req: any; }) => { const invoiceObject = event.data.object as Stripe.Invoice; const currentInvoice = await InvoiceService.getByStripeId({ - db: req.db, + db: ctx.db, stripeId: invoiceObject.id!, }); @@ -44,10 +46,10 @@ export const handleInvoiceUpdated = async ({ } if (Object.keys(updates).length > 0 && invoiceObject.id) { - await InvoiceService.updateByStripeId({ - db: req.db, - stripeId: invoiceObject.id, - updates, + await invoiceActions.updateFromStripe({ + ctx, + customerId: ctx.customerId ?? "", + stripeInvoice: invoiceObject, }); } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processAllocatedPricesForInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processAllocatedPricesForInvoiceCreated.ts index ad424ad46..f0d11ca8a 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processAllocatedPricesForInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processAllocatedPricesForInvoiceCreated.ts @@ -26,8 +26,7 @@ const processAllocatedPrice = async ({ eventContext: InvoiceCreatedContext; customerEntitlement: FullCusEntWithFullCusProduct; }) => { - const { db } = ctx; - const { stripeInvoice, fullCustomer } = eventContext; + const { stripeInvoice } = eventContext; const customerProduct = customerEntitlement.customer_product; const customerEntitlements = customerProduct?.customer_entitlements ?? []; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts index ff3302c69..9def03c45 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts @@ -1,6 +1,6 @@ import type Stripe from "stripe"; import { storeRenewalLineItems } from "@/external/stripe/webhookHandlers/common"; -import { InvoiceService } from "@/internal/invoices/InvoiceService"; +import { invoiceActions } from "@/internal/invoices/actions"; import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext"; import { setupInvoiceFinalizedContext } from "./setupInvoiceFinalizedContext"; import { processVercelInvoice } from "./tasks/processVercelInvoice"; @@ -33,9 +33,9 @@ export const handleStripeInvoiceFinalized = async ({ await processVercelInvoice({ ctx, eventContext }); // 2. Upsert Autumn invoice record - // 2. Try to update existing invoice first (works even without subscription) - const autumnInvoice = await InvoiceService.updateFromStripeInvoice({ - db: ctx.db, + const autumnInvoice = await invoiceActions.updateFromStripe({ + ctx, + customerId: ctx.fullCustomer?.id ?? "", stripeInvoice: eventContext.stripeInvoice, }); diff --git a/server/src/external/stripe/webhookHandlers/handleSubCreated.ts b/server/src/external/stripe/webhookHandlers/handleSubCreated.ts index 0e8e7e6c4..0bf2fa987 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubCreated.ts @@ -1,16 +1,4 @@ -import type { FullCustomerPrice } from "@autumn/shared"; import type Stripe from "stripe"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; -import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js"; -import { SubService } from "@/internal/subscriptions/SubService.js"; -import { generateId } from "@/utils/genUtils.js"; -import { getStripeExpandedInvoice } from "../stripeInvoiceUtils.js"; -import { - getEarliestPeriodEnd, - getEarliestPeriodStart, -} from "../stripeSubUtils/convertSubUtils.js"; import { getFullStripeSub } from "../stripeSubUtils.js"; import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; @@ -27,118 +15,99 @@ export const handleSubCreated = async ({ stripeId: stripeObject.id, }); - // 1. can ignore - if (subscription.schedule) { - const cusProds = await CusProductService.getByStripeScheduledId({ - db, - stripeScheduledId: subscription.schedule as string, - orgId: org.id, - env, - }); - - if (!cusProds || cusProds.length === 0) { - console.log("No cus prod found for scheduled id", subscription.schedule); - return; - } - - // Update autumn sub - const autumnSub = await SubService.getFromScheduleId({ - db, - scheduleId: subscription.schedule as string, - }); - - const earliestPeriodStart = getEarliestPeriodStart({ sub: subscription }); - const earliestPeriodEnd = getEarliestPeriodEnd({ sub: subscription }); - if (autumnSub) { - await SubService.updateFromScheduleId({ - db, - scheduleId: subscription.schedule as string, - updates: { - stripe_id: subscription.id, - current_period_start: earliestPeriodStart, - current_period_end: earliestPeriodEnd, - }, - }); - } else { - let subUsageFeatures = []; - try { - subUsageFeatures = JSON.parse(subscription.metadata?.usage_features); - subUsageFeatures = subUsageFeatures.map( - (feature: any) => feature.internal_id, - ); - } catch (error) { - console.log("Error parsing usage features", error); - } - - await SubService.createSub({ - db, - sub: { - id: generateId("sub"), - created_at: Date.now(), - stripe_id: subscription.id, - stripe_schedule_id: subscription.schedule as string, - usage_features: subUsageFeatures, - org_id: org.id, - env: env, - current_period_start: earliestPeriodStart, - current_period_end: earliestPeriodEnd, - }, - }); - } - - console.log( - "Handling subscription.created for scheduled cus products:", - cusProds.length, - ); - - const batchUpdate = []; - for (const cusProd of cusProds) { - const subIds = cusProd.subscription_ids - ? [...cusProd.subscription_ids] - : []; - subIds.push(subscription.id); - - const updateCusProd = async () => { - await CusProductService.update({ - ctx, - cusProductId: cusProd.id, - updates: { - subscription_ids: subIds, - }, - }); - - // Fetch latest invoice? - const stripeCli = createStripeCli({ org, env }); - const invoice = await getStripeExpandedInvoice({ - stripeCli, - stripeInvoiceId: subscription.latest_invoice as string, - }); - - const invoiceItems = await getInvoiceItems({ - stripeInvoice: invoice, - prices: cusProd.customer_prices.map( - (cpr: FullCustomerPrice) => cpr.price, - ), - logger, - }); - - await InvoiceService.createInvoiceFromStripe({ - db, - stripeInvoice: invoice, - internalCustomerId: cusProd.internal_customer_id, - internalEntityId: cusProd.internal_entity_id, - productIds: [cusProd.product_id], - internalProductIds: [cusProd.internal_product_id], - org, - items: invoiceItems, - }); - }; - - batchUpdate.push(updateCusProd()); - } - - await Promise.all(batchUpdate); - } + // // 1. can ignore + // if (subscription.schedule) { + // const cusProds = await CusProductService.getByStripeScheduledId({ + // db, + // stripeScheduledId: subscription.schedule as string, + // orgId: org.id, + // env, + // }); + + // if (!cusProds || cusProds.length === 0) { + // console.log("No cus prod found for scheduled id", subscription.schedule); + // return; + // } + + // // Update autumn sub + // const autumnSub = await SubService.getFromScheduleId({ + // db, + // scheduleId: subscription.schedule as string, + // }); + + // const earliestPeriodStart = getEarliestPeriodStart({ sub: subscription }); + // const earliestPeriodEnd = getEarliestPeriodEnd({ sub: subscription }); + // if (autumnSub) { + // await SubService.updateFromScheduleId({ + // db, + // scheduleId: subscription.schedule as string, + // updates: { + // stripe_id: subscription.id, + // current_period_start: earliestPeriodStart, + // current_period_end: earliestPeriodEnd, + // }, + // }); + // } else { + // let subUsageFeatures = []; + // try { + // subUsageFeatures = JSON.parse(subscription.metadata?.usage_features); + // subUsageFeatures = subUsageFeatures.map( + // (feature: any) => feature.internal_id, + // ); + // } catch (error) { + // console.log("Error parsing usage features", error); + // } + + // await SubService.createSub({ + // db, + // sub: { + // id: generateId("sub"), + // created_at: Date.now(), + // stripe_id: subscription.id, + // stripe_schedule_id: subscription.schedule as string, + // usage_features: subUsageFeatures, + // org_id: org.id, + // env: env, + // current_period_start: earliestPeriodStart, + // current_period_end: earliestPeriodEnd, + // }, + // }); + // } + + // console.log( + // "Handling subscription.created for scheduled cus products:", + // cusProds.length, + // ); + + // const batchUpdate = []; + // for (const cusProd of cusProds) { + // const subIds = cusProd.subscription_ids + // ? [...cusProd.subscription_ids] + // : []; + // subIds.push(subscription.id); + + // const updateCusProd = async () => { + // await CusProductService.update({ + // ctx, + // cusProductId: cusProd.id, + // updates: { + // subscription_ids: subIds, + // }, + // }); + + // // Fetch latest invoice? + // const stripeCli = createStripeCli({ org, env }); + // const invoice = await getStripeExpandedInvoice({ + // stripeCli, + // stripeInvoiceId: subscription.latest_invoice as string, + // }); + // }; + + // batchUpdate.push(updateCusProd()); + // } + + // await Promise.all(batchUpdate); + // } // Get cus prods for sub // const cusProds = await CusProductService.getByStripeSubId({ diff --git a/server/src/external/stripe/webhookMiddlewares/stripeWebhookRefreshMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeWebhookRefreshMiddleware.ts index 8064b3823..54a56542b 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeWebhookRefreshMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeWebhookRefreshMiddleware.ts @@ -1,6 +1,9 @@ import type { Context, Next } from "hono"; import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; -import type { StripeWebhookHonoEnv } from "./stripeWebhookContext.js"; +import type { + StripeWebhookContext, + StripeWebhookHonoEnv, +} from "./stripeWebhookContext.js"; const updateProductEvents = ["customer.subscription.updated"]; @@ -17,6 +20,31 @@ const updateInvoiceEvents = [ "invoice.finalized", ]; +export const shouldSkipWebhookRefresh = ({ + ctx, +}: { + ctx: StripeWebhookContext; +}): boolean => { + const { stripeEvent } = ctx; + if (!stripeEvent) return false; + + // Skip cache refresh for manual invoices — these are always Autumn-initiated + // (e.g. auto top-up, allocated invoices). The originating code path manages + // the cache directly, so a webhook-driven nuke would discard fresh data. + switch (stripeEvent.type) { + case "invoice.created": + case "invoice.finalized": + case "invoice.updated": + case "invoice.paid": { + const eventData = stripeEvent.data.object; + if (eventData?.billing_reason === "manual") return true; + return false; + } + default: + return false; + } +}; + /** * Middleware that refreshes customer cache after webhook handlers complete * Runs after the main handler (post-processing) @@ -30,7 +58,7 @@ export const stripeWebhookRefreshMiddleware = async ( // Post-processing: refresh cache const ctx = c.get("ctx"); - const { logger, org, env, stripeEvent } = ctx; + const { logger, stripeEvent } = ctx; if (!stripeEvent) return; @@ -38,6 +66,8 @@ export const stripeWebhookRefreshMiddleware = async ( const data = stripeEvent.data; try { + if (shouldSkipWebhookRefresh({ ctx })) return; + if ( coreEvents.includes(eventType) || updateProductEvents.includes(eventType) || diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index 6ace0b37d..314c8bbf4 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -7,14 +7,14 @@ import { matchRoute } from "./middlewareUtils.js"; * Route patterns that trigger customer cache deletion */ const cusPrefixedUrls = [ - { - method: "POST", - url: "/customers/:customer_id", - }, - { - method: "PATCH", - url: "/customers/:customer_id", - }, + // { + // method: "POST", + // url: "/customers/:customer_id", + // }, + // { + // method: "PATCH", + // url: "/customers/:customer_id", + // }, { method: "DELETE", url: "/customers/:customer_id", @@ -85,6 +85,12 @@ const coreUrls: { method: string; url: string; source?: string }[] = [ url: "/balances.create", source: "createBalance", }, + + // // Update customer + // { + // method: "POST", + // url: "/customers.update", + // }, ]; /** diff --git a/server/src/internal/api/check/checkUtils/getCheckData.ts b/server/src/internal/api/check/checkUtils/getCheckData.ts index ad963b3ee..3db6620fa 100644 --- a/server/src/internal/api/check/checkUtils/getCheckData.ts +++ b/server/src/internal/api/check/checkUtils/getCheckData.ts @@ -9,6 +9,7 @@ import { InternalError, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; import { getApiEntityBase } from "@/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.js"; @@ -102,6 +103,15 @@ export const getCheckData = async ({ errorOnNotFound: true, }); + // Trigger auto top-up + triggerAutoTopUp({ + ctx, + newFullCus: fullCustomer, + feature: featureToUse, + }).catch((error) => { + ctx.logger.error(`[getCheckData] Failed to trigger auto top-up: ${error}`); + }); + const apiBalance = apiEntity.balances?.[featureToUse.id]; return { diff --git a/server/src/internal/balances/autoTopUp/autoTopup.ts b/server/src/internal/balances/autoTopUp/autoTopup.ts new file mode 100644 index 000000000..392f5d2d6 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/autoTopup.ts @@ -0,0 +1,103 @@ +import { withLock } from "@/external/redis/redisUtils.js"; +import { voidStripeInvoiceIfOpen } from "@/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan.js"; +import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js"; +import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.js"; +import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan.js"; +import type { AutoTopUpPayload } from "@/queue/workflows.js"; +import { computeAutoTopupPlan } from "./compute/computeAutoTopupPlan.js"; +import { buildAutoTopUpLockKey } from "./helpers/autoTopUpUtils.js"; +import { clearAutoTopupPendingKey } from "./helpers/enqueueAutoTopupWithBurstSuppression.js"; +import { recordAutoTopupAttempt } from "./helpers/limits/index.js"; +import { logAutoTopupContext } from "./logs/logAutoTopupContext.js"; +import { setupAutoTopupContext } from "./setup/setupAutoTopupContext.js"; + +/** Workflow handler for auto top-ups. */ +export const autoTopup = async ({ + ctx, + payload, +}: { + ctx: AutumnContext; + payload: AutoTopUpPayload; +}) => { + const { org, env, logger } = ctx; + const { customerId, featureId } = payload; + + const executeAutoTopup = async () => { + const start = performance.now(); + + logger.info( + `========= RUNNING AUTO TOPUP FOR CUSTOMER ${customerId} AND FEATURE ${featureId} ========`, + ); + + // 1. Setup — fetch full customer, auto-topup config, cusEnt, Stripe context + const autoTopupContext = await setupAutoTopupContext({ ctx, payload }); + + if (!autoTopupContext) return; + + logAutoTopupContext({ ctx, autoTopupContext }); + + // 3. Compute — build line items + autumn billing plan + stripe invoice action + const { autumnBillingPlan, stripeBillingPlan } = computeAutoTopupPlan({ + ctx, + autoTopupContext, + }); + + logAutumnBillingPlan({ + ctx, + plan: autumnBillingPlan, + billingContext: autoTopupContext, + }); + logStripeBillingPlan({ + ctx, + stripeBillingPlan, + billingContext: autoTopupContext, + }); + + let billingResult: Awaited>; + billingResult = await executeBillingPlan({ + ctx, + billingContext: autoTopupContext, + billingPlan: { autumn: autumnBillingPlan, stripe: stripeBillingPlan }, + }); + + logStripeBillingResult({ ctx, result: billingResult.stripe }); + + await recordAutoTopupAttempt({ + ctx, + autoTopupContext, + billingResult, + }); + + if (billingResult.stripe?.stripeInvoice?.status !== "paid") { + await voidStripeInvoiceIfOpen({ + ctx, + stripeInvoice: billingResult.stripe?.stripeInvoice, + source: "autoTopup", + }); + return; + } + + const durationMs = Math.round(performance.now() - start); + logger.info( + `[autoTopup] Completed for feature ${featureId}, customer ${customerId}, duration: ${durationMs}ms`, + ); + }; + + try { + // 2. Execute under lock (shares attach lock to prevent concurrent attach + auto-topup) + await withLock({ + lockKey: buildAutoTopUpLockKey({ + orgId: org.id, + env, + customerId, + }), + ttlMs: 60_000, + errorMessage: `Another billing operation is already in progress for customer ${customerId}`, + fn: executeAutoTopup, + }); + } finally { + await clearAutoTopupPendingKey({ ctx, customerId, featureId }); + } +}; diff --git a/server/src/internal/balances/autoTopUp/autoTopupContext.ts b/server/src/internal/balances/autoTopUp/autoTopupContext.ts new file mode 100644 index 000000000..f3636d587 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/autoTopupContext.ts @@ -0,0 +1,13 @@ +import type { + AutoTopup, + AutoTopupLimitState, + BillingContext, + FullCusEntWithFullCusProduct, +} from "@autumn/shared"; + +export interface AutoTopupContext extends BillingContext { + autoTopupConfig: AutoTopup; + customerEntitlement: FullCusEntWithFullCusProduct; // The one-off prepaid cusEnt being topped up + + limitState: AutoTopupLimitState; +} diff --git a/server/src/internal/balances/autoTopUp/compute/computeAutoTopupPlan.ts b/server/src/internal/balances/autoTopUp/compute/computeAutoTopupPlan.ts new file mode 100644 index 000000000..804ffeaed --- /dev/null +++ b/server/src/internal/balances/autoTopUp/compute/computeAutoTopupPlan.ts @@ -0,0 +1,105 @@ +import { + type AutumnBillingPlan, + cusEntToCusPrice, + InternalError, + type LineItemContext, + orgToCurrency, + type StripeBillingPlan, + type StripeInvoiceAction, + type UsagePriceConfig, + usagePriceToLineItem, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { lineItemsToInvoiceAddLinesParams } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.js"; +import type { AutoTopupContext } from "../autoTopupContext.js"; +import { + buildUpdatedOptions, + updateCusEntOptionsInline, +} from "../helpers/autoTopUpUtils.js"; + +/** Compute the auto top-up billing plan + stripe invoice action. Returns null if line item amount is <= 0. */ +export const computeAutoTopupPlan = ({ + ctx, + autoTopupContext, +}: { + ctx: AutumnContext; + autoTopupContext: AutoTopupContext; +}): { + autumnBillingPlan: AutumnBillingPlan; + stripeBillingPlan: StripeBillingPlan; +} => { + const { org } = ctx; + const { autoTopupConfig, customerEntitlement } = autoTopupContext; + + const cusProduct = customerEntitlement.customer_product!; + const feature = customerEntitlement.entitlement.feature; + const cusPrice = cusEntToCusPrice({ cusEnt: customerEntitlement })!; + const quantity = autoTopupConfig.quantity; + + // A. Convert credits to packs (billing units) + const priceConfig = cusPrice.price.config as UsagePriceConfig; + const billingUnits = priceConfig.billing_units || 1; + const topUpPacks = new Decimal(quantity).div(billingUnits).toNumber(); + + // B. Build line item + const lineItem = usagePriceToLineItem({ + cusEnt: updateCusEntOptionsInline({ + cusEnt: customerEntitlement, + feature, + quantity: topUpPacks, + }), + context: { + price: cusPrice.price, + product: cusProduct.product, + feature, + currency: orgToCurrency({ org }), + direction: "charge", + now: Date.now(), + billingTiming: "in_advance", + } satisfies LineItemContext, + options: { + shouldProrateOverride: false, + chargeImmediatelyOverride: true, + }, + }); + + if (lineItem.amount <= 0) { + throw new InternalError({ + message: `[computeAutoTopupPlan] Calculated amount for auto top-up is ${lineItem.amount} for feature ${feature.id}, skipping`, + }); + } + + // C. Build autumn billing plan + const autumnBillingPlan: AutumnBillingPlan = { + customerId: autoTopupContext.fullCustomer?.id ?? "", + insertCustomerProducts: [], + lineItems: [lineItem], + updateCustomerEntitlements: [ + { + customerEntitlement, + balanceChange: quantity, + }, + ], + updateCustomerProduct: { + customerProduct: cusProduct, + updates: { + options: buildUpdatedOptions({ cusProduct, feature, topUpPacks }), + }, + }, + }; + + // D. Build stripe invoice action (manual — bypassing evaluateStripeBillingPlan) + const addLineParams = lineItemsToInvoiceAddLinesParams({ + lineItems: [lineItem], + }); + + const stripeInvoiceAction: StripeInvoiceAction = { + addLineParams: { lines: addLineParams }, + }; + + return { + autumnBillingPlan, + stripeBillingPlan: { invoiceAction: stripeInvoiceAction }, + }; +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/autoTopUpUtils.ts b/server/src/internal/balances/autoTopUp/helpers/autoTopUpUtils.ts new file mode 100644 index 000000000..a8f514498 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/autoTopUpUtils.ts @@ -0,0 +1,71 @@ +import { + type Feature, + type FeatureOptions, + type FullCusEntWithFullCusProduct, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; + +/** Build the Redis lock key for auto top-up. Shares the attach lock so auto-topup and attach can't run concurrently. */ +export const buildAutoTopUpLockKey = ({ + orgId, + env, + customerId, +}: { + orgId: string; + env: string; + customerId: string; +}) => { + return `lock:attach:${orgId}:${env}:${customerId}`; +}; + +/** Compute updated options array with the top-up packs added. */ +export const buildUpdatedOptions = ({ + cusProduct, + feature, + topUpPacks, +}: { + cusProduct: FullCusEntWithFullCusProduct["customer_product"]; + feature: Feature; + topUpPacks: number; +}): FeatureOptions[] => { + if (!cusProduct) return []; + + return cusProduct.options.map((opt) => { + if ( + opt.internal_feature_id === feature.internal_id || + opt.feature_id === feature.id + ) { + return { + ...opt, + quantity: new Decimal(opt.quantity || 0).add(topUpPacks).toNumber(), + }; + } + return opt; + }); +}; + +export const updateCusEntOptionsInline = ({ + cusEnt, + feature, + quantity, +}: { + cusEnt: FullCusEntWithFullCusProduct; + feature: Feature; + quantity: number; +}) => { + // const cusPrice = cusEntToCusPrice({ cusEnt }); + return { + ...cusEnt, + customer_product: cusEnt.customer_product + ? { + ...cusEnt.customer_product, + options: cusEnt.customer_product.options.map((opt) => + opt.internal_feature_id === feature.internal_id || + opt.feature_id === feature.id + ? { ...opt, quantity } + : opt, + ), + } + : cusEnt.customer_product, + }; +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/enqueueAutoTopupWithBurstSuppression.ts b/server/src/internal/balances/autoTopUp/helpers/enqueueAutoTopupWithBurstSuppression.ts new file mode 100644 index 000000000..d49506ec9 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/enqueueAutoTopupWithBurstSuppression.ts @@ -0,0 +1,79 @@ +import { redis } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { workflows } from "@/queue/workflows.js"; +import { tryRedisNx, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; + +const AUTO_TOPUP_PENDING_TTL_SECONDS = 30; + +export const buildAutoTopupPendingKey = ({ + ctx, + customerId, + featureId, +}: { + ctx: AutumnContext; + customerId: string; + featureId: string; +}) => { + const { org, env } = ctx; + return `auto_topup:pending:${org.id}:${env}:${customerId}:${featureId}`; +}; + +export const clearAutoTopupPendingKey = async ({ + ctx, + customerId, + featureId, +}: { + ctx: AutumnContext; + customerId: string; + featureId: string; +}) => { + const pendingKey = buildAutoTopupPendingKey({ ctx, customerId, featureId }); + await tryRedisWrite(() => redis.del(pendingKey)); +}; + +export const enqueueAutoTopupWithBurstSuppression = async ({ + ctx, + customerId, + featureId, +}: { + ctx: AutumnContext; + customerId: string; + featureId: string; +}) => { + const { org, env } = ctx; + const pendingKey = buildAutoTopupPendingKey({ ctx, customerId, featureId }); + + return await tryRedisNx({ + operation: () => + redis.set(pendingKey, "1", "EX", AUTO_TOPUP_PENDING_TTL_SECONDS, "NX"), + + onSuccess: async () => { + await workflows.triggerAutoTopUp({ + orgId: org.id, + env, + customerId, + featureId, + }); + + ctx.logger.info( + `[enqueueAutoTopupWithBurstSuppression] Auto top-up job enqueued for customer ${customerId} and feature ${featureId}`, + ); + + return { enqueued: true, reason: "enqueued" as const }; + }, + + onRedisUnavailable: () => { + ctx.logger.warn( + `[enqueueAutoTopupWithBurstSuppression] Redis unavailable, skipping auto top-up for customer ${customerId} and feature ${featureId}`, + ); + return { enqueued: false, reason: "redis_unavailable" as const }; + }, + + onKeyAlreadyExists: () => { + ctx.logger.warn( + `[enqueueAutoTopupWithBurstSuppression] Skipping auto top-up job for customer ${customerId} and feature ${featureId} because pending key already exists`, + ); + return { enqueued: false, reason: "pending_key_exists" as const }; + }, + }); +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/fullCustomerToAutoTopupObjects.ts b/server/src/internal/balances/autoTopUp/helpers/fullCustomerToAutoTopupObjects.ts new file mode 100644 index 000000000..c95531acc --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/fullCustomerToAutoTopupObjects.ts @@ -0,0 +1,60 @@ +import { + type AutoTopup, + cusEntsToBalance, + cusEntToCusPrice, + type FullCusEntWithFullCusProduct, + type FullCustomer, + fullCustomerToCustomerEntitlements, + isOneOffPrice, + isPrepaidPrice, + isVolumeBasedCusEnt, +} from "@autumn/shared"; + +/** Pure extraction of auto-topup-relevant objects from a FullCustomer. Returns null if any prerequisite is missing. */ +export const fullCustomerToAutoTopupObjects = ({ + fullCustomer, + featureId, +}: { + fullCustomer: FullCustomer; + featureId: string; +}): { + autoTopupConfig: AutoTopup; + customerEntitlement: FullCusEntWithFullCusProduct; + balanceBelowThreshold: boolean; +} | null => { + // 1. Find enabled auto_topup config + const autoTopupConfig = fullCustomer.auto_topups?.find( + (config) => config.feature_id === featureId && config.enabled, + ); + + if (!autoTopupConfig) return null; + + // 2. Find cusEnts for this feature + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, + featureId, + }); + + if (cusEnts.length === 0) return null; + + // 3. Find the one-off prepaid cusEnt + const customerEntitlement = cusEnts.find((ce) => { + const cp = cusEntToCusPrice({ cusEnt: ce }); + return ( + cp && + isOneOffPrice(cp.price) && + isPrepaidPrice(cp.price) && + !isVolumeBasedCusEnt(ce) + ); + }); + + if (!customerEntitlement || !customerEntitlement.customer_product) { + return null; + } + + // 4. Check balance against threshold + const remainingBalance = cusEntsToBalance({ cusEnts, withRollovers: true }); + const balanceBelowThreshold = remainingBalance < autoTopupConfig.threshold; + + return { autoTopupConfig, customerEntitlement, balanceBelowThreshold }; +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts new file mode 100644 index 000000000..9e89754b7 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts @@ -0,0 +1,96 @@ +import { + type AutoTopupPurchaseLimit, + addInterval, + EntInterval, + type InsertAutoTopupLimitState, +} from "@autumn/shared"; +import type { AutoTopupWindowLimitConfig } from "./autoTopupRateLimitConfigs.js"; + +const intervalToEntInterval = ({ + interval, +}: { + interval: + | AutoTopupPurchaseLimit["interval"] + | AutoTopupWindowLimitConfig["interval"]; +}): EntInterval => { + switch (interval) { + case "minute": + return EntInterval.Minute; + case "hour": + return EntInterval.Hour; + case "day": + return EntInterval.Day; + case "week": + return EntInterval.Week; + // case "month": + default: + return EntInterval.Month; + } +}; + +const getWindowEndsAt = ({ + now, + windowConfig, +}: { + now: number; + windowConfig: AutoTopupPurchaseLimit | AutoTopupWindowLimitConfig; +}) => { + return addInterval({ + from: now, + interval: intervalToEntInterval({ interval: windowConfig.interval }), + intervalCount: windowConfig.interval_count ?? 1, + }); +}; + +export const normalizeWindowCounter = ({ + now, + windowEndsAt, + count, + windowConfig, +}: { + now: number; + windowEndsAt: number; + count: number; + windowConfig?: AutoTopupPurchaseLimit | AutoTopupWindowLimitConfig; +}) => { + if (!windowConfig) return undefined; + + if (now < windowEndsAt) { + return { windowEndsAt, count }; + } + + return { + windowEndsAt: getWindowEndsAt({ now, windowConfig }), + count: 0, + }; +}; + +export const addToLimitsUpdate = ({ + updates, + state, + windowEndsAtField, + countField, + windowEndsAt, + count, +}: { + updates: Partial; + state: Record; + windowEndsAtField: + | "attempt_window_ends_at" + | "failed_attempt_window_ends_at" + | "purchase_window_ends_at"; + countField: "attempt_count" | "failed_attempt_count" | "purchase_count"; + windowEndsAt: number; + count: number; +}) => { + const currentWindowEndsAt = state[windowEndsAtField]; + const currentCount = state[countField]; + + if (currentWindowEndsAt !== windowEndsAt) { + updates[windowEndsAtField] = windowEndsAt; + } + + if (currentCount !== count) { + updates[countField] = count; + } +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupRateLimitConfigs.ts b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupRateLimitConfigs.ts new file mode 100644 index 000000000..3e97811a3 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupRateLimitConfigs.ts @@ -0,0 +1,32 @@ +import type { AutoTopup } from "@autumn/shared"; + +export type AutoTopupWindowLimitConfig = { + limit: number; + interval: "minute" | "hour" | "day" | "week" | "month"; + interval_count: number; +}; + +export const DEFAULT_AUTO_TOPUP_ATTEMPT_LIMIT: AutoTopupWindowLimitConfig = { + limit: 2, + interval: "minute", + interval_count: 10, +}; + +export const DEFAULT_AUTO_TOPUP_FAILED_ATTEMPT_LIMIT: AutoTopupWindowLimitConfig = + { + limit: 1, + interval: "hour", + interval_count: 1, + }; + +export const getAutoTopupRateLimitConfigs = ({ + autoTopupConfig, +}: { + autoTopupConfig: AutoTopup; +}) => { + return { + purchaseLimit: autoTopupConfig.purchase_limit, + attemptLimit: DEFAULT_AUTO_TOPUP_ATTEMPT_LIMIT, + failedAttemptLimit: DEFAULT_AUTO_TOPUP_FAILED_ATTEMPT_LIMIT, + }; +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/getOrCreateAutoTopupLimitState.ts b/server/src/internal/balances/autoTopUp/helpers/limits/getOrCreateAutoTopupLimitState.ts new file mode 100644 index 000000000..9239f0627 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/limits/getOrCreateAutoTopupLimitState.ts @@ -0,0 +1,58 @@ +import { InternalError } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { generateId } from "@/utils/genUtils.js"; +import { autoTopupLimitRepo } from "../../repos"; + +export const getOrCreateAutoTopupLimitState = async ({ + ctx, + internalCustomerId, + customerId, + featureId, + now, +}: { + ctx: AutumnContext; + internalCustomerId: string; + customerId: string; + featureId: string; + now: number; +}) => { + const existing = await autoTopupLimitRepo.findByScope({ + ctx, + internalCustomerId, + featureId, + }); + + if (existing) return existing; + + const inserted = await autoTopupLimitRepo.insert({ + ctx, + data: { + id: generateId("atlim"), + internal_customer_id: internalCustomerId, + customer_id: customerId, + feature_id: featureId, + purchase_window_ends_at: now, + purchase_count: 0, + attempt_window_ends_at: now, + attempt_count: 0, + failed_attempt_window_ends_at: now, + failed_attempt_count: 0, + updated_at: now, + }, + }); + + if (inserted) return inserted; + + const afterConflict = await autoTopupLimitRepo.findByScope({ + ctx, + internalCustomerId, + featureId, + }); + + if (afterConflict) return afterConflict; + + throw new InternalError({ + code: "auto_topup_limits_init_failed", + message: `Failed to initialize auto_topup_limits for customer ${customerId} and feature ${featureId}`, + }); +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/index.ts b/server/src/internal/balances/autoTopUp/helpers/limits/index.ts new file mode 100644 index 000000000..f0fd3c7bb --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/limits/index.ts @@ -0,0 +1,2 @@ +export { preflightAutoTopupLimits } from "./preflightAutoTopupLimits.js"; +export { recordAutoTopupAttempt } from "./recordAutoTopupAttempt.js"; diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/preflightAutoTopupLimits.ts b/server/src/internal/balances/autoTopUp/helpers/limits/preflightAutoTopupLimits.ts new file mode 100644 index 000000000..c46d2423f --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/limits/preflightAutoTopupLimits.ts @@ -0,0 +1,134 @@ +import type { + AutoTopup, + AutoTopupLimitState, + FullCustomer, + InsertAutoTopupLimitState, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { AutoTopUpPayload } from "@/queue/workflows"; +import { autoTopupLimitRepo } from "../../repos"; +import { + addToLimitsUpdate, + normalizeWindowCounter, +} from "./autoTopupLimitWindowUtils.js"; +import { getAutoTopupRateLimitConfigs } from "./autoTopupRateLimitConfigs.js"; +import { getOrCreateAutoTopupLimitState } from "./getOrCreateAutoTopupLimitState.js"; + +export const preflightAutoTopupLimits = async ({ + ctx, + payload, + fullCustomer, + autoTopupConfig, +}: { + ctx: AutumnContext; + payload: AutoTopUpPayload; + fullCustomer: FullCustomer; + autoTopupConfig: AutoTopup; +}): Promise<{ + allowed: boolean; + reason?: string; + limitState: AutoTopupLimitState; +}> => { + const now = Date.now(); + + const state = await getOrCreateAutoTopupLimitState({ + ctx, + internalCustomerId: fullCustomer.internal_id, + customerId: fullCustomer.id || fullCustomer.internal_id, + featureId: payload.featureId, + now, + }); + + const { purchaseLimit, attemptLimit, failedAttemptLimit } = + getAutoTopupRateLimitConfigs({ autoTopupConfig }); + + const normalizedAttempt = normalizeWindowCounter({ + now, + windowEndsAt: state.attempt_window_ends_at, + count: state.attempt_count, + windowConfig: attemptLimit, + })!; + + const normalizedFailedAttempt = normalizeWindowCounter({ + now, + windowEndsAt: state.failed_attempt_window_ends_at, + count: state.failed_attempt_count, + windowConfig: failedAttemptLimit, + })!; + const normalizedPurchase = normalizeWindowCounter({ + now, + windowEndsAt: state.purchase_window_ends_at, + count: state.purchase_count, + windowConfig: purchaseLimit, + }); + + const preflightUpdates: Partial = {}; + + addToLimitsUpdate({ + updates: preflightUpdates, + state, + windowEndsAtField: "attempt_window_ends_at", + countField: "attempt_count", + windowEndsAt: normalizedAttempt.windowEndsAt, + count: normalizedAttempt.count, + }); + addToLimitsUpdate({ + updates: preflightUpdates, + state, + windowEndsAtField: "failed_attempt_window_ends_at", + countField: "failed_attempt_count", + windowEndsAt: normalizedFailedAttempt.windowEndsAt, + count: normalizedFailedAttempt.count, + }); + + if (normalizedPurchase) { + addToLimitsUpdate({ + updates: preflightUpdates, + state, + windowEndsAtField: "purchase_window_ends_at", + countField: "purchase_count", + windowEndsAt: normalizedPurchase.windowEndsAt, + count: normalizedPurchase.count, + }); + } + + if (Object.keys(preflightUpdates).length > 0) { + preflightUpdates.updated_at = now; + } + + await autoTopupLimitRepo.updateById({ + ctx, + id: state.id, + updates: preflightUpdates, + }); + + if ( + purchaseLimit && + normalizedPurchase && + normalizedPurchase.count >= (purchaseLimit.limit ?? Number.MAX_SAFE_INTEGER) + ) { + return { + allowed: false, + reason: "purchase_limit_reached", + limitState: state, + }; + } + + if (normalizedAttempt.count >= attemptLimit.limit) { + return { + allowed: false, + reason: "attempt_limit_reached", + limitState: state, + }; + } + + if (normalizedFailedAttempt.count >= failedAttemptLimit.limit) { + return { + allowed: false, + reason: "failed_attempt_limit_reached", + limitState: state, + }; + } + + return { allowed: true, limitState: state }; +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/recordAutoTopupAttempt.ts b/server/src/internal/balances/autoTopUp/helpers/limits/recordAutoTopupAttempt.ts new file mode 100644 index 000000000..6fc2f50a0 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/helpers/limits/recordAutoTopupAttempt.ts @@ -0,0 +1,99 @@ +import type { BillingResult, InsertAutoTopupLimitState } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { AutoTopupContext } from "../../autoTopupContext"; +import { autoTopupLimitRepo } from "../../repos"; +import { + addToLimitsUpdate, + normalizeWindowCounter, +} from "./autoTopupLimitWindowUtils.js"; +import { getAutoTopupRateLimitConfigs } from "./autoTopupRateLimitConfigs.js"; + +export const recordAutoTopupAttempt = async ({ + ctx, + autoTopupContext, + billingResult, +}: { + ctx: AutumnContext; + autoTopupContext: AutoTopupContext; + billingResult: BillingResult; +}) => { + const now = Date.now(); + const { limitState: state, autoTopupConfig } = autoTopupContext; + const outcome = + billingResult.stripe?.stripeInvoice?.status === "paid" + ? "success" + : "failure"; + + const { purchaseLimit, attemptLimit, failedAttemptLimit } = + getAutoTopupRateLimitConfigs({ autoTopupConfig }); + + const normalizedAttempt = normalizeWindowCounter({ + now, + windowEndsAt: state.attempt_window_ends_at, + count: state.attempt_count, + windowConfig: attemptLimit, + })!; + const normalizedFailedAttempt = normalizeWindowCounter({ + now, + windowEndsAt: state.failed_attempt_window_ends_at, + count: state.failed_attempt_count, + windowConfig: failedAttemptLimit, + })!; + const normalizedPurchase = normalizeWindowCounter({ + now, + windowEndsAt: state.purchase_window_ends_at, + count: state.purchase_count, + windowConfig: purchaseLimit, + }); + + const updates: Partial = {}; + + addToLimitsUpdate({ + updates, + state, + windowEndsAtField: "attempt_window_ends_at", + countField: "attempt_count", + windowEndsAt: normalizedAttempt.windowEndsAt, + count: normalizedAttempt.count + 1, + }); + + if (state.last_attempt_at !== now) { + updates.last_attempt_at = now; + } + + if (outcome === "failure") { + addToLimitsUpdate({ + updates, + state, + windowEndsAtField: "failed_attempt_window_ends_at", + countField: "failed_attempt_count", + windowEndsAt: normalizedFailedAttempt.windowEndsAt, + count: normalizedFailedAttempt.count + 1, + }); + + if (state.last_failed_attempt_at !== now) { + updates.last_failed_attempt_at = now; + } + } + + if (outcome === "success" && purchaseLimit && normalizedPurchase) { + addToLimitsUpdate({ + updates, + state, + windowEndsAtField: "purchase_window_ends_at", + countField: "purchase_count", + windowEndsAt: normalizedPurchase.windowEndsAt, + count: normalizedPurchase.count + 1, + }); + } + + if (Object.keys(updates).length > 0) { + updates.updated_at = now; + } + + await autoTopupLimitRepo.updateById({ + ctx, + id: state.id, + updates, + }); +}; diff --git a/server/src/internal/balances/autoTopUp/logs/logAutoTopupContext.ts b/server/src/internal/balances/autoTopUp/logs/logAutoTopupContext.ts new file mode 100644 index 000000000..f36a67f66 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/logs/logAutoTopupContext.ts @@ -0,0 +1,41 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; +import type { AutoTopupContext } from "../autoTopupContext"; + +export const logAutoTopupContext = ({ + ctx, + autoTopupContext, +}: { + ctx: AutumnContext; + autoTopupContext: AutoTopupContext; +}) => { + const { autoTopupConfig, customerEntitlement, fullCustomer, stripeCustomer } = + autoTopupContext; + + const cusProduct = customerEntitlement.customer_product; + const feature = customerEntitlement.entitlement.feature; + + addToExtraLogs({ + ctx, + extras: { + autoTopupContext: { + customer: `${fullCustomer.id} (${fullCustomer.internal_id})`, + stripeCustomer: stripeCustomer.id, + + feature: `${feature.id} (${feature.internal_id})`, + customerEntitlement: `${customerEntitlement.id} | balance: ${customerEntitlement.balance}`, + customerProduct: cusProduct?.id ?? "none", + product: cusProduct?.product?.id ?? "none", + + config: { + enabled: autoTopupConfig.enabled, + threshold: autoTopupConfig.threshold, + quantity: autoTopupConfig.quantity, + purchaseLimit: autoTopupConfig.purchase_limit + ? `${autoTopupConfig.purchase_limit.limit}/${autoTopupConfig.purchase_limit.interval}` + : "none", + }, + }, + }, + }); +}; diff --git a/server/src/internal/balances/autoTopUp/repos/findAutoTopupLimitByScope.ts b/server/src/internal/balances/autoTopUp/repos/findAutoTopupLimitByScope.ts new file mode 100644 index 000000000..5fd67b164 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/repos/findAutoTopupLimitByScope.ts @@ -0,0 +1,25 @@ +import { autoTopupLimitStates } from "@autumn/shared"; +import { and, eq } from "drizzle-orm"; +import { db } from "@/db/initDrizzle.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +export const findAutoTopupLimitByScope = async ({ + ctx, + internalCustomerId, + featureId, +}: { + ctx: AutumnContext; + internalCustomerId: string; + featureId: string; +}) => { + const { org, env } = ctx; + + return await db.query.autoTopupLimits.findFirst({ + where: and( + eq(autoTopupLimitStates.org_id, org.id), + eq(autoTopupLimitStates.env, env), + eq(autoTopupLimitStates.internal_customer_id, internalCustomerId), + eq(autoTopupLimitStates.feature_id, featureId), + ), + }); +}; diff --git a/server/src/internal/balances/autoTopUp/repos/index.ts b/server/src/internal/balances/autoTopUp/repos/index.ts new file mode 100644 index 000000000..b4a3fa4e1 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/repos/index.ts @@ -0,0 +1,9 @@ +import { findAutoTopupLimitByScope } from "./findAutoTopupLimitByScope"; +import { insertAutoTopupLimit } from "./insertAutoTopupLimit"; +import { updateAutoTopupLimitById } from "./updateAutoTopupLimitById"; + +export const autoTopupLimitRepo = { + findByScope: findAutoTopupLimitByScope, + insert: insertAutoTopupLimit, + updateById: updateAutoTopupLimitById, +} as const; diff --git a/server/src/internal/balances/autoTopUp/repos/insertAutoTopupLimit.ts b/server/src/internal/balances/autoTopUp/repos/insertAutoTopupLimit.ts new file mode 100644 index 000000000..4be514e32 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/repos/insertAutoTopupLimit.ts @@ -0,0 +1,26 @@ +import { + autoTopupLimitStates, + type InsertAutoTopupLimitState, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +export const insertAutoTopupLimit = async ({ + ctx, + data, +}: { + ctx: AutumnContext; + data: Omit; +}) => { + const { db, org, env } = ctx; + const inserted = await db + .insert(autoTopupLimitStates) + .values({ + ...data, + org_id: org.id, + env, + }) + .onConflictDoNothing() + .returning(); + + return inserted[0] ?? null; +}; diff --git a/server/src/internal/balances/autoTopUp/repos/updateAutoTopupLimitById.ts b/server/src/internal/balances/autoTopUp/repos/updateAutoTopupLimitById.ts new file mode 100644 index 000000000..283745a78 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/repos/updateAutoTopupLimitById.ts @@ -0,0 +1,30 @@ +import { + autoTopupLimitStates, + type InsertAutoTopupLimitState, +} from "@autumn/shared"; +import { and, eq } from "drizzle-orm"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +export const updateAutoTopupLimitById = async ({ + ctx, + id, + updates, +}: { + ctx: AutumnContext; + id: string; + updates: Partial; +}) => { + if (Object.keys(updates).length === 0) return; + + const { db, org, env } = ctx; + await db + .update(autoTopupLimitStates) + .set(updates) + .where( + and( + eq(autoTopupLimitStates.id, id), + eq(autoTopupLimitStates.org_id, org.id), + eq(autoTopupLimitStates.env, env), + ), + ); +}; diff --git a/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts b/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts new file mode 100644 index 000000000..3b509105d --- /dev/null +++ b/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts @@ -0,0 +1,116 @@ +import { + ACTIVE_STATUSES, + BillingVersion, + cusProductToProduct, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.js"; +import type { AutoTopUpPayload } from "@/queue/workflows.js"; +import type { AutoTopupContext } from "../autoTopupContext.js"; +import { fullCustomerToAutoTopupObjects } from "../helpers/fullCustomerToAutoTopupObjects.js"; +import { preflightAutoTopupLimits } from "../helpers/limits/preflightAutoTopupLimits.js"; + +/** Fetch full customer, auto-topup config, cusEnt, and Stripe context. Returns null if any prerequisite is missing. */ +export const setupAutoTopupContext = async ({ + ctx, + payload, +}: { + ctx: AutumnContext; + payload: AutoTopUpPayload; +}): Promise => { + const { logger } = ctx; + const { customerId, featureId } = payload; + + // 1. Fetch FullCustomer — Redis cache first (has latest deducted balance), fall back to DB + let fullCustomer = await getCachedFullCustomer({ ctx, customerId }); + + if (!fullCustomer) { + fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + inStatuses: ACTIVE_STATUSES, + withSubs: true, + }); + } + + if (!fullCustomer || !fullCustomer.processor?.id) { + logger.warn( + `[setupAutoTopupContext] Customer ${customerId} not found or no Stripe customer ID, skipping`, + ); + return null; + } + + // 2. Extract auto-topup objects (config, cusEnt) from fullCustomer + const resolved = fullCustomerToAutoTopupObjects({ + fullCustomer, + featureId, + }); + + if (!resolved?.balanceBelowThreshold) { + ctx.logger.info( + `[setupAutoTopupContext] balance not below threshold, skipping`, + { + data: resolved, + }, + ); + return null; + } + + const { autoTopupConfig, customerEntitlement } = resolved; + + const { allowed, reason, limitState } = await preflightAutoTopupLimits({ + ctx, + payload, + fullCustomer, + autoTopupConfig, + }); + + if (!allowed) { + logger.info( + `[setupAutoTopupContext] Preflight blocked for feature ${featureId}, customer ${customerId}, reason: ${reason}`, + ); + return null; + } + + const { stripeCus, paymentMethod, testClockFrozenTime } = + await fetchStripeCustomerForBilling({ ctx, fullCus: fullCustomer }); + + if (!paymentMethod) { + logger.warn( + `[setupAutoTopupContext] No payment method for customer ${stripeCus.id}, skipping`, + ); + return null; + } + + const currentEpochMs = testClockFrozenTime ?? Date.now(); + + const cusProduct = customerEntitlement.customer_product; + + if (!cusProduct) { + logger.error( + `[setupAutoTopupContext] No customer product found for customer ${customerId}`, + ); + return null; + } + + return { + // BillingContext fields + fullCustomer, + fullProducts: [cusProductToProduct({ cusProduct })], + featureQuantities: [], + currentEpochMs, + billingCycleAnchorMs: "now", + resetCycleAnchorMs: "now", + stripeCustomer: stripeCus, + paymentMethod, + billingVersion: BillingVersion.V2, + + // Auto top-up specific fields + autoTopupConfig, + customerEntitlement, + + limitState, + }; +}; diff --git a/server/src/internal/balances/autoTopUp/triggerAutoTopUp.ts b/server/src/internal/balances/autoTopUp/triggerAutoTopUp.ts new file mode 100644 index 000000000..f9c2fb3a3 --- /dev/null +++ b/server/src/internal/balances/autoTopUp/triggerAutoTopUp.ts @@ -0,0 +1,42 @@ +import { + type Feature, + type FullCustomer, + getRelevantFeatures, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { enqueueAutoTopupWithBurstSuppression } from "./helpers/enqueueAutoTopupWithBurstSuppression.js"; +import { fullCustomerToAutoTopupObjects } from "./helpers/fullCustomerToAutoTopupObjects.js"; + +/** Lightweight pre-check + SQS enqueue for auto top-ups after a deduction. */ +export const triggerAutoTopUp = async ({ + ctx, + newFullCus, + feature, +}: { + ctx: AutumnContext; + newFullCus: FullCustomer; + feature: Feature; +}) => { + const relevantFeatures = getRelevantFeatures({ + features: ctx.features, + featureId: feature.id, + }); + + for (const relevantFeature of relevantFeatures) { + const resolved = fullCustomerToAutoTopupObjects({ + fullCustomer: newFullCus, + featureId: relevantFeature.id, + }); + + if (!resolved?.balanceBelowThreshold) continue; + + // Enqueue the auto top-up job + const customerId = newFullCus.id || newFullCus.internal_id; + + await enqueueAutoTopupWithBurstSuppression({ + ctx, + customerId, + featureId: relevantFeature.id, + }); + } +}; diff --git a/server/src/internal/balances/setUsage/getSetUsageDeductions.ts b/server/src/internal/balances/setUsage/getSetUsageDeductions.ts deleted file mode 100644 index 7cad1feab..000000000 --- a/server/src/internal/balances/setUsage/getSetUsageDeductions.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { - CusProductStatus, - cusEntToIncludedUsage, - ErrCode, - type Feature, - FeatureNotFoundError, - FeatureType, - type FullCustomer, - type FullCustomerEntitlement, - fullCustomerToCustomerEntitlements, - orgToInStatuses, - RecaseError, - 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 { - getCreditCost, - getCreditSystemsFromFeature, -} from "../../features/creditSystemUtils.js"; -import type { FeatureDeduction } from "../utils/types/featureDeduction.js"; - -// Helper: Check if cusEnts has balance for a feature -const cusEntsHasFeatureBalance = ({ - cusEnts, - featureInternalId, -}: { - cusEnts: FullCustomerEntitlement[]; - featureInternalId: string; -}) => { - return cusEnts.some((cusEnt) => { - if (cusEnt.internal_feature_id !== featureInternalId) { - return false; - } - // Has balance if there's a numeric balance (including 0) or unlimited - return cusEnt.balance !== null && cusEnt.balance !== undefined; - }); -}; - -// 2. Get deductions for each feature -export const getSetUsageDeductions = async ({ - ctx, - setUsageParams, - fullCustomer, -}: { - ctx: AutumnContext; - setUsageParams: SetUsageParams; - fullCustomer: FullCustomer; -}): Promise => { - const { org, features: allFeatures } = ctx; - const { value, entity_id } = setUsageParams; - - const feature = allFeatures.find((f) => f.id === setUsageParams.feature_id); - if (!feature) { - throw new FeatureNotFoundError({ - featureId: setUsageParams.feature_id, - }); - } - - const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer, - reverseOrder: org.config?.reverse_deduction_order, - featureId: feature.id, - inStatuses: orgToInStatuses({ org }), - }); - - // ========================================== - // CREDIT SYSTEM DETECTION & VALIDATION - // ========================================== - - // 1. Check if the feature being set is itself a credit system - const isSettingCreditSystem = feature.type === FeatureType.CreditSystem; - - // 2. Find all credit systems that contain this feature - const creditSystems = getCreditSystemsFromFeature({ - featureId: feature.id, - features: allFeatures, - }); - - // 3. Validate: Customer should not have both regular feature balance AND credit system balance - // (unless we're setting the credit system itself) - if (!isSettingCreditSystem && creditSystems.length > 0) { - const hasRegularFeatureBalance = cusEntsHasFeatureBalance({ - cusEnts, - featureInternalId: feature.internal_id!, - }); - - // Check each credit system - for (const creditSystem of creditSystems) { - const hasCreditSystemBalance = cusEntsHasFeatureBalance({ - cusEnts, - featureInternalId: creditSystem.internal_id!, - }); - - // If customer has balance in BOTH, that's an error - if (hasRegularFeatureBalance && hasCreditSystemBalance) { - throw new RecaseError({ - message: `Customer has balance in both feature '${feature.id}' and credit system '${creditSystem.id}'. Cannot determine which to deduct from.`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - } - } - - // ========================================== - // SMART FEATURE SELECTION FOR DEDUCTION - // ========================================== - - // 4. Decide which feature to deduct from: credit system or regular feature - let deductionFeature: Feature = feature; - - // If customer has balance in a credit system, use that for deduction - if (!isSettingCreditSystem && creditSystems.length > 0) { - for (const creditSystem of creditSystems) { - const hasCreditSystemBalance = cusEntsHasFeatureBalance({ - cusEnts, - featureInternalId: creditSystem.internal_id!, - }); - - if (hasCreditSystemBalance) { - deductionFeature = creditSystem; - break; - } - } - } - - // ========================================== - // CALCULATE DEDUCTION - // ========================================== - - const deductionCusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer, - reverseOrder: org.config?.reverse_deduction_order, - featureId: deductionFeature.id, - inStatuses: orgToInStatuses({ org }), - }); - - const { unlimited } = getUnlimitedAndUsageAllowed({ - cusEnts: deductionCusEnts, - internalFeatureId: deductionFeature.internal_id!, - }); - - if (unlimited) { - return []; - } - - const totalAllowance = sumValues( - deductionCusEnts.map((cusEnt) => - cusEntToIncludedUsage({ cusEnt, entityId: setUsageParams.entity_id }), - ), - ); - - // ========================================== - // TARGET BALANCE CALCULATION - // ========================================== - - let targetBalance: number; - - // If deducting from a credit system, calculate credit cost - if ( - deductionFeature.type === FeatureType.CreditSystem && - deductionFeature.id !== feature.id - ) { - // Calculate credit cost for the feature - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: deductionFeature, - amount: value, - }); - - targetBalance = new Decimal(totalAllowance).sub(creditCost).toNumber(); - } else { - // Regular feature or setting the credit system itself: direct subtraction - targetBalance = new Decimal(totalAllowance).sub(value).toNumber(); - } - - const totalBalance = getFeatureBalance({ - cusEnts: deductionCusEnts, - internalFeatureId: deductionFeature.internal_id!, - entityId: entity_id, - })!; - - const deduction = new Decimal(totalBalance).sub(targetBalance).toNumber(); - - if (deduction === 0) { - console.log( - ` - Skipping feature ${deductionFeature.id} -- deduction is 0`, - ); - return []; - } - - return [{ feature: deductionFeature, deduction }]; -}; diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts index c30e075da..4a698cfd2 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts @@ -72,6 +72,7 @@ export const computeAllocatedInvoicePlan = ({ }); return { + customerId: billingContext.fullCustomer?.id ?? "", updateCustomerEntitlements: [updateCustomerEntitlementPlan], lineItems, insertCustomerProducts: [], diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index 83b6ccf2c..e5ea3ad3e 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -5,6 +5,7 @@ import { } from "@autumn/shared"; import { sql } from "drizzle-orm"; import { withLock } from "@/external/redis/redisUtils.js"; +import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { CusService } from "../../../customers/CusService.js"; @@ -189,7 +190,17 @@ export const executePostgresDeduction = async ({ feature: deduction.feature, }).catch((error) => { ctx.logger.error( - `[executeRedisDeduction] Failed to handle threshold reached: ${error}`, + `[executePostgresDeduction] Failed to handle threshold reached: ${error}`, + ); + }); + + triggerAutoTopUp({ + ctx, + newFullCus: fullCustomer, + feature: deduction.feature, + }).catch((error) => { + ctx.logger.error( + `[executePostgresDeduction] Failed to trigger auto top-up: ${error}`, ); }); } diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index b75fb5064..e00459dcc 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -4,6 +4,7 @@ import type { } from "@autumn/shared"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; import { handlePaidAllocatedCusEnt } from "@/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.js"; import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.js"; import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; @@ -201,6 +202,16 @@ export const executeRedisDeduction = async ({ `[executeRedisDeduction] Failed to handle threshold reached: ${error}`, ); }); + + triggerAutoTopUp({ + ctx, + newFullCus: fullCustomer, + feature: deduction.feature, + }).catch((error) => { + ctx.logger.error( + `[executeRedisDeduction] Failed to trigger auto top-up: ${error}`, + ); + }); } return { diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts index 6a57a86db..354f4d75d 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts @@ -57,6 +57,7 @@ export const computeAttachPlan = ({ : { allLineItems: [], updateCustomerEntitlements: [] }; let plan: AutumnBillingPlan = { + customerId: attachBillingContext.fullCustomer?.id ?? "", insertCustomerProducts: [newCustomerProduct], updateCustomerProduct, deleteCustomerProduct: scheduledCustomerProduct, diff --git a/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts b/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts index d442465f4..afac357fd 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts @@ -87,6 +87,7 @@ export const computeMultiAttachPlan = ({ const allCustomEnts = productContexts.flatMap((pc) => pc.customEnts); const plan: AutumnBillingPlan = { + customerId: multiAttachBillingContext.fullCustomer?.id ?? "", insertCustomerProducts: newCustomerProducts, updateCustomerProduct, deleteCustomerProduct: scheduledCustomerProduct, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts index e3a05e166..e6b4440a4 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts @@ -45,6 +45,7 @@ export const computeUpdateSubscriptionPlan = async ({ break; case UpdateSubscriptionIntent.None: plan = { + customerId: billingContext.fullCustomer?.id ?? "", insertCustomerProducts: [], updateCustomerProduct: { customerProduct: billingContext.customerProduct, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts index d232cf2c4..4050bd87a 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -47,6 +47,7 @@ export const computeCustomPlan = async ({ }); return { + customerId: fullCustomer?.id ?? "", insertCustomerProducts: [newFullCustomerProduct], updateCustomerProduct: { customerProduct, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts index a9e66b8de..35698af10 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts @@ -30,6 +30,7 @@ export const computeUpdateQuantityPlan = ({ ); return { + customerId: updateSubscriptionContext.fullCustomer?.id ?? "", insertCustomerProducts: [], customPrices: [], customEntitlements: [], diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts index 855bdf333..ddc0cb141 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts @@ -1,5 +1,6 @@ import type { AutumnBillingPlan } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { customerEntitlementActions } from "@/internal/customers/cusProducts/cusEnts/actions"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService"; @@ -38,18 +39,17 @@ export const updateCustomerEntitlements = async ({ continue; } - // 2. Handle balance change - if (balanceChange > 0) { - await CusEntService.increment({ - ctx, - id: customerEntitlement.id, - amount: balanceChange, - }); - } else if (balanceChange < 0) { - await CusEntService.decrement({ + // 2. Handle balance change (DB + cache) + if (balanceChange !== 0) { + const customerId = + customerEntitlement.customer_id ?? + customerEntitlement.internal_customer_id; + + await customerEntitlementActions.adjustBalanceDbAndCache({ ctx, - id: customerEntitlement.id, - amount: Math.abs(balanceChange), + customerId, + cusEntId: customerEntitlement.id, + delta: balanceChange, }); } diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index d40f8429c..7622b92ed 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -3,8 +3,9 @@ import type Stripe from "stripe"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts"; import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements"; +import { customerProductActions } from "@/internal/customers/cusProducts/actions"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; -import { InvoiceService } from "@/internal/invoices/InvoiceService"; +import { invoiceActions } from "@/internal/invoices/actions"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService"; import { PriceService } from "@/internal/products/prices/PriceService"; @@ -55,21 +56,19 @@ export const executeAutumnBillingPlan = async ({ }); } - // ctx.logger.debug( - // `[execAutumnPlan] inserting new customer products: ${insertCustomerProducts.map((cp) => cp.product.id).join(", ")}`, - // ); // 2. Insert new customer products await insertNewCusProducts({ ctx, newCusProducts: insertCustomerProducts, }); - // 3. Update customer product options + // 3. Update customer product (DB + cache) if (updateCustomerProduct) { const { customerProduct, updates } = updateCustomerProduct; - await CusProductService.update({ + await customerProductActions.updateDbAndCache({ ctx, + customerId: autumnBillingPlan.customerId, cusProductId: customerProduct.id, updates, }); @@ -102,13 +101,14 @@ export const executeAutumnBillingPlan = async ({ // 7. Upsert invoice (if provided) if (!autumnInvoice && autumnBillingPlan.upsertInvoice) { - autumnInvoice = await InvoiceService.upsert({ - db, + autumnInvoice = await invoiceActions.upsertToDbAndCache({ + ctx, + customerId: autumnBillingPlan.customerId, invoice: autumnBillingPlan.upsertInvoice, }); } - // 8. Trigger workflow to store invoice line items (async via SQS) + // 9. Trigger workflow to store invoice line items (async via SQS) if (autumnInvoice && stripeInvoice) { await workflows.triggerStoreInvoiceLineItems({ orgId: ctx.org.id, @@ -119,7 +119,7 @@ export const executeAutumnBillingPlan = async ({ }); } - // 9. Trigger workflow to store deferred line items (ProrateNextCycle pending items) + // 10. Trigger workflow to store deferred line items (ProrateNextCycle pending items) // These are invoice items created without an invoice — stored with invoice_id = null if ( stripeInvoiceItems && diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts index 4584925ba..2c6360506 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts @@ -10,7 +10,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { shouldDeferBillingPlan } from "@/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan"; import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling"; import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode"; -import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; +import { invoiceActions } from "@/internal/invoices/actions"; import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; export const executeStripeInvoiceAction = async ({ @@ -68,11 +68,17 @@ export const executeStripeInvoiceAction = async ({ resumeAfter: StripeBillingStage.InvoiceAction, }); - autumnInvoice = await upsertInvoiceFromBilling({ + // autumnInvoice = await upsertInvoiceFromBilling({ + // ctx, + // stripeInvoice: invoice, + // fullProducts: billingContext.fullProducts, + // fullCustomer: billingContext.fullCustomer, + // }); + autumnInvoice = await invoiceActions.upsertFromStripe({ ctx, stripeInvoice: invoice, - fullProducts: billingContext.fullProducts, fullCustomer: billingContext.fullCustomer, + fullProducts: billingContext.fullProducts, }); return { @@ -85,12 +91,18 @@ export const executeStripeInvoiceAction = async ({ if (invoice) { logger.debug("[executeStripeInvoiceAction] Upserting invoice from billing"); - autumnInvoice = await upsertInvoiceFromBilling({ + autumnInvoice = await invoiceActions.upsertFromStripe({ ctx, stripeInvoice: invoice, - fullProducts: billingContext.fullProducts, fullCustomer: billingContext.fullCustomer, + fullProducts: billingContext.fullProducts, }); + // autumnInvoice = await upsertInvoiceFromBilling({ + // ctx, + // stripeInvoice: invoice, + // fullProducts: billingContext.fullProducts, + // fullCustomer: billingContext.fullCustomer, + // }); } logger.debug( diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index ed3b049b2..5a0d332b3 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -16,8 +16,8 @@ import { finalizeStripeInvoice } from "@/internal/billing/v2/providers/stripe/ut import { executeStripeSubscriptionOperation } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation"; import { getLatestInvoiceFromSubscriptionAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction"; import { getRequiredActionFromSubscriptionInvoice } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/getRequiredActionFromSubscriptionInvoice"; -import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling"; +import { invoiceActions } from "@/internal/invoices/actions"; import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; export const executeStripeSubscriptionAction = async ({ @@ -94,11 +94,11 @@ export const executeStripeSubscriptionAction = async ({ let autumnInvoice: Invoice | undefined; if (latestStripeInvoice) { logger.debug(`[execSubAction] Upserting invoice from billing`); - autumnInvoice = await upsertInvoiceFromBilling({ + autumnInvoice = await invoiceActions.upsertFromStripe({ ctx, stripeInvoice: latestStripeInvoice, - fullProducts: billingContext.fullProducts, fullCustomer: billingContext.fullCustomer, + fullProducts: billingContext.fullProducts, }); } diff --git a/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts b/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts index 974af7c7a..42e5b3dae 100644 --- a/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts +++ b/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts @@ -21,6 +21,7 @@ export const upsertInvoiceFromBilling = async ({ fullProducts, fullCustomer, }); + await InvoiceService.upsert({ db: ctx.db, invoice }); return invoice; diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 77a2355e7..d799c4732 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -28,7 +28,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { withSpan } from "../analytics/tracer/spanUtils.js"; import { resetCustomerEntitlements } from "./actions/resetCustomerEntitlements/resetCustomerEntitlements.js"; import { RELEVANT_STATUSES } from "./cusProducts/CusProductService.js"; -import { updateCachedCustomerData } from "./cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.js"; import { getFullCusQuery } from "./getFullCusQuery.js"; // const tracer = trace.getTracer("express"); @@ -371,12 +370,6 @@ export class CusService { if (results && results.length > 0) { const customer = results[0] as Customer; - await updateCachedCustomerData({ - ctx, - customerId: idOrInternalId, - updates: update, - }); - return customer; } else { return null; diff --git a/server/src/internal/customers/actions/createWithDefaults/compute/computeCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/compute/computeCreateCustomerPlan.ts index 0fc9d0f8e..e13ae5050 100644 --- a/server/src/internal/customers/actions/createWithDefaults/compute/computeCreateCustomerPlan.ts +++ b/server/src/internal/customers/actions/createWithDefaults/compute/computeCreateCustomerPlan.ts @@ -1,5 +1,5 @@ -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnBillingPlan } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { initFullCustomerProductFromProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct.js"; import type { CreateCustomerContext } from "../createCustomerContext.js"; @@ -29,5 +29,5 @@ export const computeCreateCustomerPlan = ({ context.fullCustomer.customer_products = insertCustomerProducts; - return { insertCustomerProducts }; + return { customerId: fullCustomer?.id ?? "", insertCustomerProducts }; }; diff --git a/server/src/internal/customers/actions/update/updateCustomer.ts b/server/src/internal/customers/actions/update/updateCustomer.ts index 0a245557f..a11fb3f95 100644 --- a/server/src/internal/customers/actions/update/updateCustomer.ts +++ b/server/src/internal/customers/actions/update/updateCustomer.ts @@ -11,6 +11,7 @@ import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { CusService } from "@/internal/customers/CusService"; +import { updateCachedCustomerData } from "../../cusUtils/fullCustomerCacheUtils/updateCachedCustomerData"; export const updateCustomer = async ({ ctx, @@ -23,6 +24,7 @@ export const updateCustomer = async ({ const { customer_id: customerId, new_customer_id: newCustomerId, + billing_controls, ...newCusData } = params; @@ -109,6 +111,9 @@ export const updateCustomer = async ({ ...oldMetadata, ...newMetadata, }, + ...(billing_controls && { + auto_topups: billing_controls.auto_topups, + }), }; if (newStripeId) { @@ -127,5 +132,12 @@ export const updateCustomer = async ({ update: updateData, }); + await updateCachedCustomerData({ + ctx, + customerId: originalCustomer.id || originalCustomer.internal_id, + newCustomerId: newCustomerId ?? undefined, + updates: updateData, + }); + return newCustomerId ?? customerId; }; diff --git a/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts b/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts index 9b1c82fb0..0d296f3fc 100644 --- a/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts +++ b/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts @@ -56,6 +56,7 @@ export const activateFreeDefaultProduct = async ({ await executeAutumnBillingPlan({ ctx, autumnBillingPlan: { + customerId: fullCustomer?.id ?? "", insertCustomerProducts: [newCustomerProduct], }, }); diff --git a/server/src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts b/server/src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts new file mode 100644 index 000000000..11ac46ac5 --- /dev/null +++ b/server/src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts @@ -0,0 +1,85 @@ +import type { InsertCustomerProduct } from "@autumn/shared"; +import type { RepoContext } from "@/db/repoContext.js"; +import { redis } from "@/external/redis/initRedis.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; + +type UpdateCachedCustomerProductResult = { + ok: boolean; + updatedCount?: number; + error?: string; +}; + +/** + * Atomically updates specific fields on a cusProduct in the Redis + * FullCustomer cache. Matches by cusProduct id and applies targeted + * JSON.SET per field. CRDT-safe. + */ +export const updateCachedCustomerProduct = async ({ + ctx, + customerId, + cusProductId, + updates, +}: { + ctx: RepoContext; + customerId: string; + cusProductId: string; + updates: Partial; +}): Promise => { + try { + if (!customerId) { + ctx.logger.warn( + `[updateCachedCustomerProduct] Skipping cache update for cusProduct ${cusProductId} because customerId is missing`, + ); + return null; + } + + const { org, env, logger } = ctx; + + const cacheKey = buildFullCustomerCacheKey({ + orgId: org.id, + env, + customerId, + }); + + const result = await tryRedisWrite(async () => { + return await redis.updateCustomerProduct( + cacheKey, + JSON.stringify({ + cus_product_id: cusProductId, + updates, + }), + ); + }); + + if (result === null) { + logger.warn( + `[updateCachedCustomerProduct] Redis write failed for cusProduct ${cusProductId}`, + ); + return null; + } + + const parsed = JSON.parse(result) as { + ok: boolean; + updated_count?: number; + error?: string; + }; + + if (!parsed.ok) { + logger.warn( + `[updateCachedCustomerProduct] Lua script error for cusProduct ${cusProductId}: ${parsed.error}`, + ); + } + + return { + ok: parsed.ok, + updatedCount: parsed.updated_count, + error: parsed.error, + }; + } catch (error) { + ctx.logger.error( + `[updateCachedCustomerProduct] cusProduct ${cusProductId}: error, ${error}`, + ); + return null; + } +}; diff --git a/server/src/internal/customers/cusProducts/actions/index.ts b/server/src/internal/customers/cusProducts/actions/index.ts index 929495899..2d95a8307 100644 --- a/server/src/internal/customers/cusProducts/actions/index.ts +++ b/server/src/internal/customers/cusProducts/actions/index.ts @@ -7,6 +7,7 @@ import { getExpiredCustomerProductsCache, setExpiredCustomerProductsCache, } from "./expiredCache"; +import { updateCustomerProductDbAndCache } from "./updateDbAndCache"; export const customerProductActions = { /** Expires a customer product and activates default if no other active in group */ @@ -21,6 +22,9 @@ export const customerProductActions = { /** Deletes any scheduled main customer product in the same group */ deleteScheduled: deleteScheduledCustomerProduct, + /** Updates a customer product in both Postgres and the Redis FullCustomer cache */ + updateDbAndCache: updateCustomerProductDbAndCache, + /** Cache operations for expired customer products (used by subscription.deleted → invoice.created) */ expiredCache: { set: setExpiredCustomerProductsCache, diff --git a/server/src/internal/customers/cusProducts/actions/updateDbAndCache.ts b/server/src/internal/customers/cusProducts/actions/updateDbAndCache.ts new file mode 100644 index 000000000..18535a4b3 --- /dev/null +++ b/server/src/internal/customers/cusProducts/actions/updateDbAndCache.ts @@ -0,0 +1,34 @@ +import type { InsertCustomerProduct } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { CusProductService } from "../CusProductService.js"; +import { updateCachedCustomerProduct } from "./cache/updateCachedCustomerProduct.js"; + +/** + * Updates a customer product in both Postgres and the Redis FullCustomer cache. + */ +export const updateCustomerProductDbAndCache = async ({ + ctx, + customerId, + cusProductId, + updates, +}: { + ctx: AutumnContext; + customerId: string; + cusProductId: string; + updates: Partial; +}) => { + const result = await CusProductService.update({ + ctx, + cusProductId, + updates, + }); + + await updateCachedCustomerProduct({ + ctx, + customerId, + cusProductId, + updates, + }); + + return result; +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/actions/adjustBalanceDbAndCache.ts b/server/src/internal/customers/cusProducts/cusEnts/actions/adjustBalanceDbAndCache.ts new file mode 100644 index 000000000..b98274aa9 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/actions/adjustBalanceDbAndCache.ts @@ -0,0 +1,33 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { CusEntService } from "../CusEntitlementService.js"; +import { incrementCachedCusEntBalance } from "./cache/incrementCachedCusEntBalance.js"; + +/** + * Adjusts a cusEnt balance in both Postgres and the Redis FullCustomer cache. + * Positive delta increments, negative delta decrements. + */ +export const adjustBalanceDbAndCache = async ({ + ctx, + customerId, + cusEntId, + delta, +}: { + ctx: AutumnContext; + customerId: string; + cusEntId: string; + delta: number; +}) => { + if (delta === 0) return; + + if (delta > 0) { + await CusEntService.increment({ ctx, id: cusEntId, amount: delta }); + } else { + await CusEntService.decrement({ + ctx, + id: cusEntId, + amount: Math.abs(delta), + }); + } + + await incrementCachedCusEntBalance({ ctx, customerId, cusEntId, delta }); +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts new file mode 100644 index 000000000..54184aa6e --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts @@ -0,0 +1,74 @@ +import type { RepoContext } from "@/db/repoContext.js"; +import { redis } from "@/external/redis/initRedis.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; + +type IncrementCachedCusEntBalanceResult = { + ok: boolean; + newBalance?: number; + newCacheVersion?: number; +}; + +/** + * Atomically increments a cusEnt's balance (and cache_version) in the Redis + * FullCustomer cache via JSON.NUMINCRBY. Safe with concurrent deductions. + */ +export const incrementCachedCusEntBalance = async ({ + ctx, + customerId, + cusEntId, + delta, +}: { + ctx: RepoContext; + customerId: string; + cusEntId: string; + delta: number; +}): Promise => { + try { + const { org, env, logger } = ctx; + + const cacheKey = buildFullCustomerCacheKey({ + orgId: org.id, + env, + customerId, + }); + + const result = await tryRedisWrite(async () => { + return await redis.adjustCustomerEntitlementBalance( + cacheKey, + JSON.stringify({ cus_ent_id: cusEntId, delta }), + ); + }); + + if (result === null) { + logger.warn( + `[incrementCachedCusEntBalance] Redis write failed for cusEnt ${cusEntId}`, + ); + return null; + } + + const parsed = JSON.parse(result) as { + ok: boolean; + new_balance?: number; + new_cache_version?: number; + error?: string; + }; + + if (!parsed.ok) { + logger.warn( + `[incrementCachedCusEntBalance] Lua script error for cusEnt ${cusEntId}: ${parsed.error}`, + ); + } + + return { + ok: parsed.ok, + newBalance: parsed.new_balance, + newCacheVersion: parsed.new_cache_version, + }; + } catch (error) { + ctx.logger.error( + `[incrementCachedCusEntBalance] cusEnt ${cusEntId}: error, ${error}`, + ); + return null; + } +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/actions/index.ts b/server/src/internal/customers/cusProducts/cusEnts/actions/index.ts new file mode 100644 index 000000000..5cc6debc1 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/actions/index.ts @@ -0,0 +1,6 @@ +import { adjustBalanceDbAndCache } from "./adjustBalanceDbAndCache"; + +export const customerEntitlementActions = { + /** Adjusts a cusEnt balance in both Postgres and the Redis FullCustomer cache */ + adjustBalanceDbAndCache: adjustBalanceDbAndCache, +}; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index 21db4bbdb..81c015f46 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -61,6 +61,7 @@ export const getApiCustomerBase = async ({ purchases: apiPurchases, balances: apiBalances, send_email_receipts: fullCus.send_email_receipts ?? false, + billing_controls: { auto_topups: fullCus.auto_topups ?? undefined }, invoices: fullCus.invoices && ctx.expand.includes(CustomerExpand.Invoices) diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts index d5c0ae697..858b04f24 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts @@ -1,9 +1,16 @@ -import type { FullCustomer } from "@autumn/shared"; +import { + CusProductStatus, + type FullCusProduct, + type FullCustomer, + FullCustomerSchema, + type Invoice, +} from "@autumn/shared"; import { Decimal } from "decimal.js"; import type { Redis } from "ioredis"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js"; +import { normalizeFromSchema } from "@/utils/cacheUtils/normalizeFromSchema.js"; import { resetCustomerEntitlements } from "../../actions/resetCustomerEntitlements/resetCustomerEntitlements.js"; import { buildFullCustomerCacheKey } from "./fullCustomerCacheConfig.js"; @@ -81,6 +88,32 @@ const roundFullCustomerBalances = ( return fullCustomer; }; +/** Deduplicates invoices by ID and sorts by created_at DESC, id DESC (matching getFullCusQuery). */ +const deduplicateFullCustomerInvoices = ( + fullCustomer: FullCustomer, +): Invoice[] => { + const idToInvoice = new Map(); + for (const invoice of fullCustomer.invoices ?? []) { + idToInvoice.set(invoice.id, invoice); + } + + return Array.from(idToInvoice.values()).sort((a, b) => { + if (b.created_at !== a.created_at) return b.created_at - a.created_at; + return b.id < a.id ? -1 : b.id > a.id ? 1 : 0; + }); +}; + +/** Strips expired customer products from the full customer. */ +const filterExpiredCustomerProducts = ( + fullCustomer: FullCustomer, +): FullCusProduct[] => { + return ( + fullCustomer.customer_products?.filter((cusProduct) => { + return cusProduct.status !== CusProductStatus.Expired; + }) ?? [] + ); +}; + /** * Get FullCustomer from Redis cache. Lazily resets stale entitlements. * @returns FullCustomer if found, null if not in cache @@ -110,7 +143,10 @@ export const getCachedFullCustomer = async ({ if (!cached) return undefined; - const fullCustomer = JSON.parse(cached) as FullCustomer; + const fullCustomer = normalizeFromSchema({ + schema: FullCustomerSchema, + data: JSON.parse(cached), + }); if (entityId) { fullCustomer.entity = fullCustomer.entities?.find((e) => e.id === entityId); @@ -128,6 +164,10 @@ export const getCachedFullCustomer = async ({ fullCustomer.send_email_receipts = false; } + fullCustomer.invoices = deduplicateFullCustomerInvoices(fullCustomer); + + fullCustomer.customer_products = filterExpiredCustomerProducts(fullCustomer); + // Lazy reset stale entitlements (DB + in-memory + cache via Lua) await resetCustomerEntitlements({ ctx, fullCus: fullCustomer }); diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts index 89c33ce50..f3a6b65d0 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts @@ -1,7 +1,8 @@ import type { Customer } from "@autumn/shared"; -import type { RepoContext } from "@/db/repoContext.js"; import { redis } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { deleteCachedFullCustomer } from "./deleteCachedFullCustomer.js"; import { buildFullCustomerCacheKey } from "./fullCustomerCacheConfig.js"; type UpdateCustomerDataResult = { @@ -19,6 +20,7 @@ type CustomerDataUpdates = Pick< | "send_email_receipts" | "processor" | "processors" + | "auto_topups" >; /** @@ -28,25 +30,36 @@ type CustomerDataUpdates = Pick< export const updateCachedCustomerData = async ({ ctx, customerId, + newCustomerId, updates, }: { - ctx: RepoContext; + ctx: AutumnContext; customerId: string; + newCustomerId?: string; updates: CustomerDataUpdates; }): Promise => { try { const { org, env, logger } = ctx; - if (Object.keys(updates).length === 0) { - return { success: true, updatedFields: [] }; - } - const cacheKey = buildFullCustomerCacheKey({ orgId: org.id, env, customerId, }); + if (newCustomerId && newCustomerId !== customerId) { + await deleteCachedFullCustomer({ + ctx, + customerId, + source: "updateCachedCustomerData (ID changed)", + }); + return { success: true, updatedFields: ["id"] }; + } + + if (Object.keys(updates).length === 0) { + return { success: true, updatedFields: [] }; + } + const paramsJson = JSON.stringify({ updates }); const result = await tryRedisWrite(async () => { diff --git a/server/src/internal/customers/cusUtils/initCustomer.ts b/server/src/internal/customers/cusUtils/initCustomer.ts index 1d11bf284..e7a46e3be 100644 --- a/server/src/internal/customers/cusUtils/initCustomer.ts +++ b/server/src/internal/customers/cusUtils/initCustomer.ts @@ -34,6 +34,7 @@ const initCustomer = ({ } : null, send_email_receipts: customerData?.send_email_receipts ?? false, + auto_topups: customerData?.billing_controls?.auto_topups, }; }; diff --git a/server/src/internal/invoices/InvoiceService.ts b/server/src/internal/invoices/InvoiceService.ts index 153a4a70d..63afbbea6 100644 --- a/server/src/internal/invoices/InvoiceService.ts +++ b/server/src/internal/invoices/InvoiceService.ts @@ -10,7 +10,6 @@ import { type Organization, stripeToAtmnAmount, } from "@autumn/shared"; -import { buildConflictUpdateColumns } from "@server/db/dbUtils.js"; import type { DrizzleCli } from "@server/db/initDrizzle.js"; import { getInvoiceDiscounts } from "@server/external/stripe/stripeInvoiceUtils.js"; import { generateId } from "@server/utils/genUtils.js"; @@ -119,6 +118,7 @@ export class InvoiceService { return invoice as Invoice; } + /** @deprecated */ static async createInvoiceFromStripe({ db, stripeInvoice, @@ -212,56 +212,52 @@ export class InvoiceService { return newInvoice; } - static async updateByStripeId({ + static async update({ db, - stripeId, + query, updates, }: { db: DrizzleCli; - stripeId: string; + query: { + id?: string; + stripeId?: string; + }; updates: Partial; }) { const results = await db .update(invoices) .set(updates) - .where(eq(invoices.stripe_id, stripeId)) + .where( + and( + query.id ? eq(invoices.id, query.id) : undefined, + query.stripeId ? eq(invoices.stripe_id, query.stripeId) : undefined, + ), + ) .returning(); - if (results.length === 0) { - return null; - } + if (results.length === 0) return null; return results[0] as Invoice; } - static async updateFromStripeInvoice({ + static async upsert({ db, - stripeInvoice, + invoice, }: { db: DrizzleCli; - stripeInvoice: Stripe.Invoice; + invoice: InsertInvoice; }) { - return await InvoiceService.updateByStripeId({ - db, - stripeId: stripeInvoice.id!, - updates: { - status: stripeInvoice.status as InvoiceStatus, - hosted_invoice_url: stripeInvoice.hosted_invoice_url, - discounts: getInvoiceDiscounts({ - expandedInvoice: stripeInvoice, - }), - }, - }); - } - - static async upsert({ db, invoice }: { db: DrizzleCli; invoice: Invoice }) { - const updateColumns = buildConflictUpdateColumns(invoices, ["id"]); const result = await db .insert(invoices) - .values(invoice as any) + .values(invoice) .onConflictDoUpdate({ target: invoices.stripe_id, - set: updateColumns, + set: { + status: invoice.status, + hosted_invoice_url: invoice.hosted_invoice_url, + discounts: invoice.discounts, + total: invoice.total, + }, }) .returning(); diff --git a/server/src/internal/invoices/actions/cache/upsertInvoiceInCache.ts b/server/src/internal/invoices/actions/cache/upsertInvoiceInCache.ts new file mode 100644 index 000000000..f13081dfd --- /dev/null +++ b/server/src/internal/invoices/actions/cache/upsertInvoiceInCache.ts @@ -0,0 +1,79 @@ +import type { Invoice } from "@autumn/shared"; +import { redis } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; + +type UpsertInvoiceAction = "appended" | "updated"; + +type UpsertInvoiceResult = { + success: boolean; + action?: UpsertInvoiceAction; + cacheMiss?: boolean; +}; + +/** + * Upsert an invoice in the customer's invoices array in the Redis cache. + * Matches by stripe_id — replaces if found, appends if not. CRDT-safe. + */ +export const upsertInvoiceInCache = async ({ + ctx, + customerId, + invoice, +}: { + ctx: AutumnContext; + customerId: string; + invoice: Invoice; +}): Promise => { + const { org, env, logger } = ctx; + + try { + if (!customerId) { + logger.warn( + `[upsertInvoiceInCache] Skipping cache update for invoice ${invoice.stripe_id} because customerId is missing`, + ); + return null; + } + + const cacheKey = buildFullCustomerCacheKey({ + orgId: org.id, + env, + customerId, + }); + + const invoiceJson = JSON.stringify(invoice); + + const result = await tryRedisWrite(async () => { + return await redis.upsertInvoiceInCustomer(cacheKey, invoiceJson); + }); + + if (result === null) { + logger.warn( + `[upsertInvoiceInCache] Redis write failed for customer ${customerId}, invoice ${invoice.stripe_id}`, + ); + return null; + } + + const parsed = JSON.parse(result) as { + success: boolean; + action?: UpsertInvoiceAction; + cache_miss?: boolean; + }; + + logger.info( + `[upsertInvoiceInCache] customer: ${customerId}, stripe_id: ${invoice.stripe_id}, action: ${parsed.action ?? "none"}${parsed.cache_miss ? ", cache_miss" : ""}`, + ); + + return { + success: parsed.success, + action: parsed.action, + cacheMiss: parsed.cache_miss, + }; + } catch (error) { + logger.error( + `[upsertInvoiceInCache] Error upserting invoice ${invoice.stripe_id} for customer ${customerId}`, + error, + ); + return null; + } +}; diff --git a/server/src/internal/invoices/actions/index.ts b/server/src/internal/invoices/actions/index.ts new file mode 100644 index 000000000..6469ef5c5 --- /dev/null +++ b/server/src/internal/invoices/actions/index.ts @@ -0,0 +1,9 @@ +import { updateInvoiceFromStripe } from "./updateFromStripe"; +import { upsertInvoiceToDbAndCache } from "./upsertDbAndCache"; +import { upsertInvoiceFromStripe } from "./upsertFromStripe"; + +export const invoiceActions = { + upsertFromStripe: upsertInvoiceFromStripe, + updateFromStripe: updateInvoiceFromStripe, + upsertToDbAndCache: upsertInvoiceToDbAndCache, +} as const; diff --git a/server/src/internal/invoices/actions/updateFromStripe.ts b/server/src/internal/invoices/actions/updateFromStripe.ts new file mode 100644 index 000000000..1046955e2 --- /dev/null +++ b/server/src/internal/invoices/actions/updateFromStripe.ts @@ -0,0 +1,44 @@ +import { stripeToAtmnAmount } from "@autumn/shared"; +import type { Stripe } from "stripe"; +import { getInvoiceDiscounts } from "@/external/stripe/stripeInvoiceUtils"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { InvoiceService } from "../InvoiceService"; +import { upsertInvoiceInCache } from "./cache/upsertInvoiceInCache"; + +export const updateInvoiceFromStripe = async ({ + ctx, + customerId, + stripeInvoice, +}: { + ctx: AutumnContext; + customerId: string; + stripeInvoice: Stripe.Invoice; +}) => { + const updatedInvoice = await InvoiceService.update({ + db: ctx.db, + query: { + stripeId: stripeInvoice.id!, + }, + updates: { + status: stripeInvoice.status ?? (undefined as string | undefined), + hosted_invoice_url: stripeInvoice.hosted_invoice_url, + discounts: getInvoiceDiscounts({ + expandedInvoice: stripeInvoice, + }), + total: stripeToAtmnAmount({ + amount: stripeInvoice.total, + currency: stripeInvoice.currency, + }), + }, + }); + + if (updatedInvoice) { + await upsertInvoiceInCache({ + ctx, + customerId, + invoice: updatedInvoice, + }); + } + + return updatedInvoice; +}; diff --git a/server/src/internal/invoices/actions/upsertDbAndCache.ts b/server/src/internal/invoices/actions/upsertDbAndCache.ts new file mode 100644 index 000000000..6d53cd3be --- /dev/null +++ b/server/src/internal/invoices/actions/upsertDbAndCache.ts @@ -0,0 +1,27 @@ +import type { InsertInvoice, Invoice } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { InvoiceService } from "../InvoiceService"; +import { upsertInvoiceInCache } from "./cache/upsertInvoiceInCache"; + +export const upsertInvoiceToDbAndCache = async ({ + ctx, + customerId, + invoice, +}: { + ctx: AutumnContext; + customerId: string; + invoice: InsertInvoice; +}): Promise => { + const { db } = ctx; + const upsertedInvoice = await InvoiceService.upsert({ db, invoice }); + + // Upsert invoice in cache + if (upsertedInvoice) { + await upsertInvoiceInCache({ + ctx, + customerId, + invoice: upsertedInvoice, + }); + } + return upsertedInvoice; +}; diff --git a/server/src/internal/invoices/actions/upsertFromStripe.ts b/server/src/internal/invoices/actions/upsertFromStripe.ts new file mode 100644 index 000000000..8e9df2290 --- /dev/null +++ b/server/src/internal/invoices/actions/upsertFromStripe.ts @@ -0,0 +1,40 @@ +import type { FullCustomer, FullProduct, Invoice } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { InvoiceService } from "@/internal/invoices/InvoiceService"; +import { initInvoiceFromStripe } from "@/internal/invoices/utils/initInvoiceFromStripe"; +import { upsertInvoiceInCache } from "./cache/upsertInvoiceInCache"; + +export const upsertInvoiceFromStripe = async ({ + ctx, + stripeInvoice, + fullCustomer, + fullProducts, + internalEntityId, +}: { + ctx: AutumnContext; + stripeInvoice: Stripe.Invoice; + fullCustomer: FullCustomer; + fullProducts: FullProduct[]; + internalEntityId?: string; +}): Promise => { + const invoice = await initInvoiceFromStripe({ + ctx, + stripeInvoice, + fullProducts, + fullCustomer, + internalEntityId, + }); + + const upsertedInvoice = await InvoiceService.upsert({ db: ctx.db, invoice }); + + // Upsert invoice in cache + if (upsertedInvoice) { + await upsertInvoiceInCache({ + ctx, + customerId: fullCustomer.id ?? "", + invoice: upsertedInvoice, + }); + } + return upsertedInvoice; +}; diff --git a/server/src/internal/invoices/invoiceUtils.ts b/server/src/internal/invoices/invoiceUtils.ts index d845d4995..ea1c3a6ed 100644 --- a/server/src/internal/invoices/invoiceUtils.ts +++ b/server/src/internal/invoices/invoiceUtils.ts @@ -60,9 +60,11 @@ export const insertInvoiceFromAttach = async ({ ); if (invoice) { - await InvoiceService.updateByStripeId({ + await InvoiceService.update({ db, - stripeId: stripeInvoice.id!, + query: { + stripeId: stripeInvoice.id!, + }, updates: { product_ids: productIds, internal_product_ids: internalProductIds, diff --git a/server/src/internal/invoices/utils/initInvoiceFromStripe.ts b/server/src/internal/invoices/utils/initInvoiceFromStripe.ts index bb5fc654b..094ec9f37 100644 --- a/server/src/internal/invoices/utils/initInvoiceFromStripe.ts +++ b/server/src/internal/invoices/utils/initInvoiceFromStripe.ts @@ -1,8 +1,8 @@ import { + deduplicateArray, type FullCustomer, type FullProduct, - type Invoice, - type InvoiceStatus, + type InsertInvoice, secondsToMs, stripeToAtmnAmount, } from "@autumn/shared"; @@ -20,17 +20,24 @@ export const initInvoiceFromStripe = async ({ stripeInvoice, fullProducts, fullCustomer, + internalEntityId, }: { ctx: AutumnContext; stripeInvoice: Stripe.Invoice; fullProducts: FullProduct[]; fullCustomer: FullCustomer; -}): Promise => { - const productIds = fullProducts.map((p) => p.id); - const internalProductIds = fullProducts.map((p) => p.internal_id); + internalEntityId?: string; +}): Promise => { + const productIds = deduplicateArray(fullProducts.map((p) => p.id)); + const internalProductIds = deduplicateArray( + fullProducts.map((p) => p.internal_id), + ); const internalCustomerId = fullCustomer.internal_id; - const internalEntityId = fullCustomer.entity?.internal_id; + + if (!internalEntityId) { + internalEntityId = fullCustomer.entity?.internal_id; + } const autumnInvoiceItems = await getInvoiceItems({ stripeInvoice, @@ -51,7 +58,7 @@ export const initInvoiceFromStripe = async ({ created_at: secondsToMs(stripeInvoice.created), stripe_id: stripeInvoice.id!, hosted_invoice_url: stripeInvoice.hosted_invoice_url || null, - status: stripeInvoice.status as InvoiceStatus | null, + status: stripeInvoice.status as string | undefined, internal_entity_id: internalEntityId || null, total: atmnTotal, currency: stripeInvoice.currency, diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index 7f66b5aee..7ce161fac 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -24,6 +24,7 @@ export enum JobName { BatchResetCusEnts = "batch-reset-cus-ents", + AutoTopUp = "auto-top-up", /** Stores invoice line items from Stripe to DB (async to allow extra API calls) */ StoreInvoiceLineItems = "store-invoice-line-items", diff --git a/server/src/queue/bullmq/initBullMqWorkers.ts b/server/src/queue/bullmq/initBullMqWorkers.ts index 942262f80..17ba7c679 100644 --- a/server/src/queue/bullmq/initBullMqWorkers.ts +++ b/server/src/queue/bullmq/initBullMqWorkers.ts @@ -3,6 +3,7 @@ import type { Logger } from "pino"; import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; +import { autoTopup } from "@/internal/balances/autoTopUp/autoTopup.js"; import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js"; @@ -132,6 +133,19 @@ const initWorker = ({ id, db }: { id: number; db: DrizzleCli }) => { ctx, payload: job.data, }); + return; + } + + if (job.name === JobName.AutoTopUp) { + if (!ctx) { + workerLogger.error("No context found for auto top-up job"); + return; + } + await autoTopup({ + ctx, + payload: job.data, + }); + return; } } catch (error: any) { workerLogger.error(`Failed to process bullmq job: ${job.name}`, { diff --git a/server/src/queue/createWorkerContext.ts b/server/src/queue/createWorkerContext.ts index 918e35b87..ada238cf8 100644 --- a/server/src/queue/createWorkerContext.ts +++ b/server/src/queue/createWorkerContext.ts @@ -54,6 +54,8 @@ export const createWorkerContext = async ({ org, env, features, + customerId, + db, logger: workerLogger, diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index 8ccf0ebcd..ac7c711c7 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -3,7 +3,9 @@ import * as Sentry from "@sentry/bun"; import type { Logger } from "pino"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; +import { autoTopup } from "@/internal/balances/autoTopUp/autoTopup.js"; import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js"; @@ -19,6 +21,7 @@ import { detectBaseVariant } from "@/internal/products/productUtils/detectProduc import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js"; import { generateId } from "@/utils/genUtils.js"; import { addWorkflowToLogs } from "@/utils/logging/addContextToLogs.js"; +import { maskExtraLogs } from "@/utils/logging/maskExtraLogs.js"; import { setSentryTags } from "../external/sentry/sentryUtils.js"; import { createWorkerContext } from "./createWorkerContext.js"; import { JobName } from "./JobName.js"; @@ -58,7 +61,9 @@ export const processMessage = async ({ workerLogger.info(`Processing message: ${job.name}`); - try { + let workerCtx: AutumnContext | undefined; + + const executeJob = async () => { if (job.name === JobName.DetectBaseVariant) { await detectBaseVariant({ db, @@ -83,6 +88,7 @@ export const processMessage = async ({ payload: job.data, logger: workerLogger, }); + workerCtx = ctx; if (ctx) { setSentryTags({ @@ -201,6 +207,18 @@ export const processMessage = async ({ return; } + if (job.name === JobName.AutoTopUp) { + if (!ctx) { + workerLogger.error("No context found for auto top-up job"); + return; + } + await autoTopup({ + ctx, + payload: job.data, + }); + return; + } + if (job.name === JobName.StoreInvoiceLineItems) { if (!ctx) { workerLogger.error("No context found for store invoice line items job"); @@ -226,6 +244,10 @@ export const processMessage = async ({ }); return; } + }; + + try { + await executeJob(); } catch (error) { Sentry.captureException(error); if (error instanceof Error) { @@ -237,5 +259,19 @@ export const processMessage = async ({ }, }); } + } finally { + if (workerCtx && Object.keys(workerCtx.extraLogs).length > 0) { + const maskedLogs = maskExtraLogs(workerCtx.extraLogs); + workerLogger.info(`[${job.name}] Finished`, { + extras: maskedLogs, + done: true, + }); + + if (process.env.NODE_ENV === "development") { + workerLogger.debug( + `FINISHED PROCESSING JOB ${job.name}, EXTRA LOGS: ${JSON.stringify(maskedLogs, null, 2)}`, + ); + } + } } }; diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index e859af8e1..2c544edc0 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -49,6 +49,12 @@ export interface Payloads { [JobName.GenerateFeatureDisplay]: GenerateFeatureDisplayPayload; [JobName.SendProductsUpdated]: SendProductsUpdatedPayload; [JobName.BatchResetCusEnts]: BatchResetCusEntsPayload; + [JobName.AutoTopUp]: { + orgId: string; + env: AppEnv; + customerId: string; + featureId: string; + }; [JobName.VerifyCacheConsistency]: { customerId: string; orgId: string; diff --git a/server/src/queue/workflows.ts b/server/src/queue/workflows.ts index 442563ee9..d810458c3 100644 --- a/server/src/queue/workflows.ts +++ b/server/src/queue/workflows.ts @@ -46,6 +46,12 @@ export type BatchResetCusEntsPayload = { }[]; }; +export type AutoTopUpPayload = { + orgId: string; + env: AppEnv; + customerId: string; + featureId: string; +}; export type StoreInvoiceLineItemsPayload = { orgId: string; env: AppEnv; @@ -102,6 +108,11 @@ const workflowRegistry = { runner: "sqs", } as WorkflowConfig, + autoTopUp: { + jobName: JobName.AutoTopUp, + runner: "sqs", + } as WorkflowConfig, + storeInvoiceLineItems: { jobName: JobName.StoreInvoiceLineItems, runner: "sqs", @@ -187,6 +198,8 @@ export const workflows = { options?: TriggerOptions, ) => triggerWorkflow({ name: "batchResetCusEnts", payload, options }), + triggerAutoTopUp: (payload: AutoTopUpPayload, options?: TriggerOptions) => + triggerWorkflow({ name: "autoTopUp", payload, options }), triggerStoreInvoiceLineItems: ( payload: StoreInvoiceLineItemsPayload, options?: TriggerOptions, diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts index 544bc8a3b..70fef1590 100644 --- a/server/src/utils/cacheUtils/cacheUtils.ts +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -5,6 +5,46 @@ import { logger } from "../../external/logtail/logtailUtils.js"; const tracer = trace.getTracer("redis"); +/** + * Executes a Redis SET ... NX and routes the three possible outcomes to callbacks: + * - `"OK"` (key was set) → `onSuccess` + * - `null` (key already exists) → `onKeyAlreadyExists` + * - Redis unavailable / error → `onRedisUnavailable` + */ +export const tryRedisNx = async < + TUnavailable, + TSuccess, + TExists, +>({ + operation, + redisInstance, + onRedisUnavailable, + onSuccess, + onKeyAlreadyExists, +}: { + operation: () => Promise<"OK" | null>; + redisInstance?: Redis; + onRedisUnavailable: () => TUnavailable | Promise; + onSuccess: () => TSuccess | Promise; + onKeyAlreadyExists: () => TExists | Promise; +}): Promise => { + const targetRedis = redisInstance ?? redis; + + try { + if (targetRedis.status !== "ready") { + logger.error("Redis not ready, skipping NX write"); + return await onRedisUnavailable(); + } + + const result = await operation(); + if (result === "OK") return await onSuccess(); + return await onKeyAlreadyExists(); + } catch (error) { + logger.error(`Redis NX write failed: ${error}`); + return await onRedisUnavailable(); + } +}; + /** * Executes a Redis write operation with automatic fallback handling. * Returns the result of the operation if successful, null if Redis is unavailable or operation fails. diff --git a/server/src/utils/cacheUtils/normalizeFromSchema.ts b/server/src/utils/cacheUtils/normalizeFromSchema.ts new file mode 100644 index 000000000..0571cf41f --- /dev/null +++ b/server/src/utils/cacheUtils/normalizeFromSchema.ts @@ -0,0 +1,132 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: accessing Zod internal properties */ +import type { z } from "zod/v4"; + +/** + * Normalize data from Redis cache to fix cjson quirks. + * + * Different Redis providers handle cjson.encode differently: + * - Empty objects {} may become empty arrays [] + * - null values may become undefined + * - Empty arrays [] may become empty objects {} + * + * This function dynamically normalizes data based on a Zod schema structure. + */ + +const getSchemaType = (schema: z.ZodTypeAny): string | undefined => { + return (schema as any)._def?.type; +}; + +const unwrapSchema = (schema: z.ZodTypeAny): z.ZodTypeAny => { + const type = getSchemaType(schema); + const def = (schema as any)._def; + + if (type === "optional") return unwrapSchema(def.innerType); + if (type === "nullable") return unwrapSchema(def.innerType); + if (type === "effects") return unwrapSchema(def.schema); + if (type === "default") return unwrapSchema(def.innerType); + + return schema; +}; + +const isNullable = (schema: z.ZodTypeAny): boolean => { + const type = getSchemaType(schema); + const def = (schema as any)._def; + + if (type === "nullable") return true; + + if (type === "optional" || type === "effects" || type === "default") { + const innerSchema = def.innerType || def.schema; + return isNullable(innerSchema); + } + + return false; +}; + +const isEmptyObject = (value: unknown): boolean => { + return ( + value !== null && + value !== undefined && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0 + ); +}; + +/** Normalize cjson empty table to array. */ +export const normalizeToArray = (value: unknown): unknown[] => { + if (Array.isArray(value)) return value; + if (value && typeof value === "object" && Object.keys(value).length === 0) + return []; + return []; +}; + +/** Dynamically normalize data based on Zod schema structure. */ +export const normalizeFromSchema = ({ + schema, + data, +}: { + schema: z.ZodTypeAny; + data: unknown; +}): T => { + if (data === undefined && isNullable(schema)) { + return null as T; + } + + const unwrapped = unwrapSchema(schema); + const type = getSchemaType(unwrapped); + + if (type === "record") { + if (Array.isArray(data) && data.length === 0) { + return {} as T; + } + + if (data && typeof data === "object" && !Array.isArray(data)) { + const valueSchema = (unwrapped as any)._def.valueType; + const normalized: Record = {}; + + for (const key in data as Record) { + normalized[key] = normalizeFromSchema({ + schema: valueSchema, + data: (data as Record)[key], + }); + } + + return normalized as T; + } + } + + if (type === "array") { + if (isEmptyObject(data)) { + return [] as T; + } + + if (Array.isArray(data)) { + const itemSchema = (unwrapped as any)._def.element; + return data.map((item) => + normalizeFromSchema({ schema: itemSchema, data: item }), + ) as T; + } + } + + if (type === "object") { + if (!data || typeof data !== "object" || Array.isArray(data)) { + return data as T; + } + + const shape = (unwrapped as any)._def.shape; + const normalized: Record = { + ...(data as Record), + }; + + for (const key in shape) { + normalized[key] = normalizeFromSchema({ + schema: shape[key], + data: normalized[key], + }); + } + + return normalized as T; + } + + return data as T; +}; diff --git a/server/src/workers.ts b/server/src/workers.ts index 0e4ad87c0..893f9a459 100644 --- a/server/src/workers.ts +++ b/server/src/workers.ts @@ -14,7 +14,7 @@ import cluster from "node:cluster"; import { initInfisical } from "./external/infisical/initInfisical.js"; // Number of worker processes (defaults to CPU cores) -const NUM_PROCESSES = process.env.NODE_ENV === "development" ? 1 : 4; +const NUM_PROCESSES = process.env.NODE_ENV === "development" ? 3 : 4; // Track if we're shutting down let isShuttingDown = false; diff --git a/server/tests/_groups/domains/temp.ts b/server/tests/_groups/domains/temp.ts new file mode 100644 index 000000000..e193af8dc --- /dev/null +++ b/server/tests/_groups/domains/temp.ts @@ -0,0 +1,8 @@ +import type { TestGroup } from "../types"; + +export const temp: TestGroup = { + name: "temp", + description: "Tests created in this current session", + tier: "domain", + paths: ["auto-topup"], +}; diff --git a/server/tests/integration/balances/auto-topup/auto-topup-basic.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-basic.test.ts new file mode 100644 index 000000000..9eb1abd2d --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-basic.test.ts @@ -0,0 +1,400 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5, CustomerBillingControls } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectCustomerProductOptions } from "@tests/integration/utils/expectCustomerProductOptions"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; + +/** Wait time for SQS auto top-up processing */ +const AUTO_TOPUP_WAIT_MS = 20000; + +const makeAutoTopupConfig = ({ + threshold = 20, + quantity = 100, + enabled = true, +}: { + threshold?: number; + quantity?: number; + enabled?: boolean; +} = {}): CustomerBillingControls => ({ + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled, + threshold, + quantity, + }, + ], +}); + +test.concurrent(`${chalk.yellowBright("auto-topup basic: track below threshold triggers top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-b1", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1, ctx } = await initScenario({ + customerId: "auto-topup-b1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Configure auto top-up: threshold=20, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Verify starting balance + const before = await autumnV2_1.customers.get(customerId); + expect(before.balances[TestFeature.Messages].remaining).toBe(100); + + // Track 85 → balance drops to 15 (below threshold of 20) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + // Wait for auto top-up to process via SQS + await timeout(AUTO_TOPUP_WAIT_MS); + + // Balance should be: 100 - 85 + 100 = 115 + const after = await autumnV2_1.customers.get(customerId); + const expectedBalance = new Decimal(100).sub(85).add(100).toNumber(); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: expectedBalance, + }); + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // Verify cusProduct.options.quantity incremented correctly (in packs). + // Initial: 100 credits / 100 billingUnits = 1 pack. + // After 1 top-up of 100 credits (1 pack): expected = 2 packs. + await expectCustomerProductOptions({ + ctx, + customerId, + productId: oneOffProd.id, + featureId: TestFeature.Messages, + quantity: 2, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup basic: track above threshold does NOT trigger")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.base({ + id: "topup-b2", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-b2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Configure auto top-up: threshold=20, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Get initial invoice count + const before = await autumnV2_1.customers.get(customerId); + const initialInvoiceCount = before.invoices?.length ?? 0; + + // Track 50 → balance drops to 50 (above threshold of 20) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Balance should remain at 50 — no top-up + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 50, + }); + + expect(initialInvoiceCount).toBe(1); + await expectCustomerInvoiceCorrect({ + customerId, + count: 1, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup basic: disabled config does NOT trigger")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.base({ + id: "topup-b3", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-b3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Configure auto top-up but DISABLED + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + enabled: false, + }), + }); + + // Track 85 → balance drops to 15 (below threshold) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Balance should stay at 15 — disabled, no top-up + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 15, + }); + await expectCustomerInvoiceCorrect({ + customerId, + count: 1, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup basic: sequential tracks each trigger separate top-ups")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-b4", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1, ctx } = await initScenario({ + customerId: "auto-topup-b4", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + // Configure auto top-up: threshold=30, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 30, + quantity: 100, + }), + }); + + // Starting balance: 200 + // Track 180 → balance = 20 (below threshold 30) → top-up fires → balance = 120 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 180, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + // 200 - 180 + 100 = 120 + const expectedMid = new Decimal(200).sub(180).add(100).toNumber(); + expectBalanceCorrect({ + customer: mid, + featureId: TestFeature.Messages, + remaining: expectedMid, + }); + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // Track 100 → balance = 20 (below threshold 30 again) → second top-up fires → balance = 120 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + // 120 - 100 + 100 = 120 + const expectedAfter = new Decimal(120).sub(100).add(100).toNumber(); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: expectedAfter, + }); + await expectCustomerInvoiceCorrect({ + customerId, + count: 3, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // Verify cusProduct.options.quantity after two top-ups (in packs). + // Initial: 200 credits / 100 billingUnits = 2 packs. + // After 2 top-ups of 100 credits (1 pack each): expected = 4 packs. + await expectCustomerProductOptions({ + ctx, + customerId, + productId: oneOffProd.id, + featureId: TestFeature.Messages, + quantity: 4, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup basic: cache and DB agree after top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-b5", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-b5", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Track 85 → triggers top-up + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + // Wait for top-up + DB sync + await timeout(AUTO_TOPUP_WAIT_MS); + + // Verify cached balance (from Redis) + const cached = await autumnV2_1.customers.get(customerId); + const expectedBalance = new Decimal(100).sub(85).add(100).toNumber(); + expectBalanceCorrect({ + customer: cached, + featureId: TestFeature.Messages, + remaining: expectedBalance, + }); + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // Wait additional time for DB sync to settle + await timeout(3000); + + // Verify DB balance (skip cache) + const fromDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + remaining: expectedBalance, + }); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-concurrent.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-concurrent.test.ts new file mode 100644 index 000000000..c590b25f7 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-concurrent.test.ts @@ -0,0 +1,247 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5, CustomerBillingControls } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; + +/** Wait time for SQS auto top-up processing (concurrent needs more time) */ +const AUTO_TOPUP_WAIT_MS = 20000; + +const makeAutoTopupConfig = ({ + threshold = 20, + quantity = 100, +}: { + threshold?: number; + quantity?: number; +} = {}): CustomerBillingControls => ({ + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold, + quantity, + }, + ], +}); + +test.concurrent(`${chalk.yellowBright("auto-topup concurrent: burst of concurrent tracks — only one top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.base({ + id: "topup-c1", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-c1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Configure auto top-up: threshold=50, quantity=100 + // With 100 starting balance, any track of 51+ should cross threshold + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + }), + }); + + // Fire 3 concurrent tracks of 20 each (total: 60 deducted) + // After all deductions: balance = 100 - 60 = 40 (below threshold 50) + // Multiple jobs may be enqueued, but only ONE should execute (balance re-check) + await Promise.all([ + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }), + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }), + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }), + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }), + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }), + ]); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Expected: 100 - 60 + 100 = 140 (exactly ONE top-up of 100) + const after = await autumnV2_1.customers.get(customerId); + const balance = after.balances[TestFeature.Messages].remaining; + const expectedBalance = new Decimal(100).sub(100).add(100).toNumber(); + + expect(balance).toBe(expectedBalance); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup concurrent: 5 concurrent small tracks — at most one top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 1, + price: 0.1, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-c2", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-c2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 20 }], + }), + ], + }); + + await autumnV2_1.customers.get(customerId); // set customer in cache! + + // Configure threshold=20, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Fire 5 concurrent tracks of 18 each (total: 90 deducted) + // Final balance after deductions: 100 - 90 = 10 (below threshold 20) + // Multiple SQS jobs enqueued, but handler re-checks balance — only one should execute + await Promise.all( + Array.from({ length: 5 }, () => + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 2, + }), + ), + ); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + const balance = after.balances[TestFeature.Messages].remaining; + + // Expected: 100 - 90 + 100 = 110 (exactly one top-up) + const expectedBalance = new Decimal(20).sub(10).add(100).toNumber(); + expect(balance).toBe(expectedBalance); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // Check DB balance + const afterDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + const balanceDb = afterDb.balances[TestFeature.Messages].remaining; + expect(balanceDb).toBe(expectedBalance); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup concurrent: sequential drain → top-up → drain → top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-c3", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-c3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Round 1: Track 85 → balance = 15 → top-up fires → balance = 115 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const midBalance = mid.balances[TestFeature.Messages].remaining; + const expectedMid = new Decimal(100).sub(85).add(100).toNumber(); + expect(midBalance).toBe(expectedMid); + + // Round 2: Track 100 → balance = 15 → second top-up fires → balance = 115 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + const afterBalance = after.balances[TestFeature.Messages].remaining; + const expectedAfter = new Decimal(115).sub(100).add(100).toNumber(); + expect(afterBalance).toBe(expectedAfter); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-config.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-config.test.ts new file mode 100644 index 000000000..40c433685 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-config.test.ts @@ -0,0 +1,215 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + BillingInterval, + type CustomerBillingControls, + PurchaseLimitInterval, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +const autoTopupConfig: CustomerBillingControls = { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 20, + quantity: 100, + }, + ], +}; + +test.concurrent(`${chalk.yellowBright("auto-topup config: update customer with billing_controls")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-cfg1", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-cfg1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.get(customerId); // set customer in cache! + + // Update customer with billing_controls + await autumnV2_1.customers.update(customerId, { + billing_controls: autoTopupConfig, + }); + + // Verify config is persisted in customer response + const customer = await autumnV2_1.customers.get(customerId); + + expect(customer.billing_controls).toBeDefined(); + expect(customer.billing_controls?.auto_topups).toHaveLength(1); + expect(customer.billing_controls?.auto_topups?.[0]).toMatchObject({ + feature_id: TestFeature.Messages, + enabled: true, + threshold: 20, + quantity: 100, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup config: disable auto_topup")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-cfg2", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-cfg2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.get(customerId); // set customer in cache! + + // Set enabled config first + await autumnV2_1.customers.update(customerId, { + billing_controls: autoTopupConfig, + }); + + // Now disable it + await autumnV2_1.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: false, + threshold: 20, + quantity: 100, + }, + ], + }, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expect(customer.billing_controls?.auto_topups?.[0]?.enabled).toBe(false); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup config: remove auto_topup with empty array")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-cfg3", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-cfg3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.get(customerId); // set customer in cache! + // Set config + await autumnV2_1.customers.update(customerId, { + billing_controls: autoTopupConfig, + }); + + // Remove by setting empty array + await autumnV2_1.customers.update(customerId, { + billing_controls: { auto_topups: [] }, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + // Either undefined/null or empty array — both are acceptable + const topups = customer.billing_controls?.auto_topups; + expect(!topups || topups.length === 0).toBe(true); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup config: with max_purchases rate limit")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-cfg4", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-cfg4", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 20, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 5, + }, + }, + ], + }, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expect( + customer.billing_controls?.auto_topups?.[0]?.purchase_limit, + ).toMatchObject({ + interval: BillingInterval.Month, + limit: 5, + }); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts new file mode 100644 index 000000000..b545274a5 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts @@ -0,0 +1,199 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5, LimitedItem } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; + +const oneOffCredits = ({ + includedUsage = 0, + billingUnits = 100, + price = 10, +}: { + includedUsage?: number; + billingUnits?: number; + price?: number; +} = {}): LimitedItem => + constructPrepaidItem({ + featureId: TestFeature.Credits, + price, + billingUnits, + includedUsage, + isOneOff: true, + }) as LimitedItem; + +/** Wait time for SQS auto top-up processing */ +const AUTO_TOPUP_WAIT_MS = 40000; + +// ═══════════════════════════════════════════════════════════════════ +// CS1: Action track depletes credits below threshold → auto top-up fires +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("auto-topup cs1: action track depletes credits → auto top-up fires")}`, async () => { + const creditsItem = oneOffCredits({ billingUnits: 1, price: 0.1 }); + const prod = products.base({ + id: "topup-cs1", + items: [creditsItem], + }); + + const { customerId, autumnV2_1, ctx } = await initScenario({ + customerId: "auto-topup-cs1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Credits, quantity: 200 }], + }), + ], + }); + + const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits); + + // Starting balance: 200 credits + // Configure auto top-up on credits feature: threshold=30, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Credits, + enabled: true, + threshold: 30, + quantity: 100, + }, + ], + }, + }); + + // Action1 costs 0.2 credits per unit + // Track 850 units of action1 → 850 × 0.2 = 170 credits deducted + // Balance: 200 - 170 = 30 → AT threshold (>= 30) → does NOT trigger + const action1Cost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: 850, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 850, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const midCredits = mid.balances[TestFeature.Credits]?.remaining; + const expectedMid = new Decimal(200).sub(action1Cost).toNumber(); + expect(midCredits).toBe(expectedMid); // 30, no top-up + + // Now track 100 units of action2 → 100 × 0.6 = 60 credits deducted + // Balance: 30 - 60 = -30... but balance can't go negative with prepaid + // Actually, the deduction will bring it below threshold → auto top-up fires + // Let's track a smaller amount: 10 units of action1 → 10 × 0.2 = 2 credits + // Balance: 30 - 2 = 28 → below threshold (28 < 30) → auto top-up fires → balance = 28 + 100 = 128 + const action1CostSmall = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: 10, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 10, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + const expectedAfter = new Decimal(expectedMid) + .sub(action1CostSmall) + .add(100) + .toNumber(); + + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Credits, + remaining: expectedAfter, + }); +}); +// ═══════════════════════════════════════════════════════════════════ +// CS2: Action track depletes credits below threshold → auto top-up fires +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("auto-topup cs2: action track depletes credits at one go → auto top-up fires")}`, async () => { + const creditsItem = oneOffCredits({ billingUnits: 1, price: 0.1 }); + const prod = products.base({ + id: "topup-cs2", + items: [creditsItem], + }); + + const { customerId, autumnV2_1, ctx } = await initScenario({ + customerId: "auto-topup-cs2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Credits, quantity: 200 }], + }), + ], + }); + + const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits); + + // Starting balance: 200 credits + // Configure auto top-up on credits feature: threshold=30, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Credits, + enabled: true, + threshold: 30, + quantity: 100, + }, + ], + }, + }); + + // Action1 costs 0.2 credits per unit + // Track 900 units of action1 → 900 × 0.2 = 180 credits deducted + // Balance: 200 - 180 = 20 → auto top-up fires + const action1Cost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: 900, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 900, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const midCredits = mid.balances[TestFeature.Credits]?.remaining; + const expectedMid = new Decimal(200).sub(action1Cost).add(100).toNumber(); + expect(midCredits).toBe(expectedMid); // 20, auto top-up fires + + await expectCustomerInvoiceCorrect({ + customerId: customerId, + count: 2, + latestTotal: 100 * 0.1, + latestStatus: "paid", + }); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-edge-cases.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-edge-cases.test.ts new file mode 100644 index 000000000..9d0d78a98 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-edge-cases.test.ts @@ -0,0 +1,354 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { makeAutoTopupConfig } from "@tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; + +/** Wait time for SQS auto top-up processing */ +const AUTO_TOPUP_WAIT_MS = 40000; + +test.concurrent(`${chalk.yellowBright("auto-topup ec1: disabling config prevents subsequent top-ups")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-ec1", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-ec1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 30, + quantity: 100, + }), + }); + + // Round 1: Track 180 → balance=20 → top-up fires → balance=120 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 180, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const midBalance = mid.balances[TestFeature.Messages].remaining; + const expectedMid = new Decimal(200).sub(180).add(100).toNumber(); + expect(midBalance).toBe(expectedMid); + + // Disable auto top-up between rounds + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ enabled: false }), + }); + + // Round 2: Track 100 → balance=20 → below threshold, but config is disabled → no top-up + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(20); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup ec2: balance depleted to exactly 0 — triggers top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-ec2", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-ec2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Track exactly 100 → balance = 0 → 0 < 20 → should trigger top-up + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(100); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup ec3: quantity < threshold — re-triggers on every track")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-ec3", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-ec3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + // threshold=100, quantity=50 → after top-up, balance will still be below threshold + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 100, + quantity: 50, + }), + }); + + // Round 1: Track 170 → balance=30 → top-up fires → balance=80 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 170, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const midBalance = mid.balances[TestFeature.Messages].remaining; + const expectedMid = new Decimal(200).sub(170).add(50).toNumber(); + expect(midBalance).toBe(expectedMid); + + // Round 2: Track just 1 → balance=79 → still below threshold 100 → ANOTHER top-up fires + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + const afterBalance = after.balances[TestFeature.Messages].remaining; + const expectedAfter = new Decimal(80).sub(1).add(50).toNumber(); + expect(afterBalance).toBe(expectedAfter); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup ec5: lowered threshold respected on next trigger")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-ec5", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-ec5", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + // Start with threshold=50 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + }), + }); + + // Round 1: Track 160 → balance=40 → 40 < 50 → top-up fires → balance=140 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 160, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const midBalance = mid.balances[TestFeature.Messages].remaining; + const expectedMid = new Decimal(200).sub(160).add(100).toNumber(); + expect(midBalance).toBe(expectedMid); + + // Lower threshold to 10 between rounds + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 10, + quantity: 100, + }), + }); + + // Round 2: Track 105 → balance=35 → above new threshold (10) → no top-up + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 105, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(35); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup ec6: tiered one-off — tier 1 then tier 2 pricing")}`, async () => { + const tieredItem = items.tieredOneOffMessages({ + includedUsage: 50, + billingUnits: 100, + tiers: [ + { to: 200, amount: 10 }, + { to: "inf", amount: 5 }, + ], + }); + const prod = products.base({ + id: "topup-ec6-tiered", + items: [tieredItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-ec6", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Initial balance: 50 included + 100 purchased = 150 + + // Round 1: quantity=100 → 1 pack, entirely within tier 1 (0–200) + // Price: 100 × ($10/100) = $10 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 30, + quantity: 100, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 130, + }); + // balance = 150 - 130 = 20 → below threshold → auto top-up fires (100 units) + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after1 = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after1, + featureId: TestFeature.Messages, + remaining: 120, // 20 + 100 + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: prod.id, + }); + + // Round 2: increase quantity to 300 → 3 packs, crosses tier boundary + // Graduated pricing on the single 300-unit top-up: + // First 200 in tier 1: 200 × ($10/100) = $20 + // Remaining 100 in tier 2: 100 × ($5/100) = $5 + // Total = $25 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 30, + quantity: 300, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + // balance = 120 - 100 = 20 → below threshold → auto top-up fires (300 units) + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after2 = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after2, + featureId: TestFeature.Messages, + remaining: 320, // 20 + 300 + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 3, + latestTotal: 25, // tier 1: $20 + tier 2: $5 + latestStatus: "paid", + latestInvoiceProductId: prod.id, + }); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-failure-modes.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-failure-modes.test.ts new file mode 100644 index 000000000..c1d7461ee --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-failure-modes.test.ts @@ -0,0 +1,311 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { makeAutoTopupConfig } from "@tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; + +/** Wait time for SQS auto top-up processing */ +const AUTO_TOPUP_WAIT_MS = 40000; + +test.concurrent(`${chalk.yellowBright("auto-topup fm1: declining card — no balance increment")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-fm1", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-fm1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + s.removePaymentMethod(), + s.attachPaymentMethod({ type: "fail" }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 15, + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "void", + latestInvoiceProductId: prod.id, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup fm2: no payment method — no top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-fm2", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-fm2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + s.removePaymentMethod(), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(15); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup fm3: monthly (non-one-off) feature — no trigger")}`, async () => { + const monthlyItem = items.monthlyMessages({ includedUsage: 100 }); + const prod = products.base({ + id: "topup-fm3-monthly", + items: [monthlyItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-fm3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [s.attach({ productId: prod.id })], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(15); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup fm4: track exactly to threshold — no trigger")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-fm4", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-fm4", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Track 80 → balance = exactly 20 (AT threshold, not below it) + // threshold check is `remainingBalance >= autoTopupConfig.threshold` + // so balance=20 >= threshold=20 → does NOT trigger + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 80, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(20); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup fm5: insufficient balance rejection does NOT double-trigger")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-fm5", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-fm5", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const midBalance = mid.balances[TestFeature.Messages].remaining; + const expectedMid = new Decimal(100).sub(85).add(100).toNumber(); + expect(midBalance).toBe(expectedMid); // 115 + + const midInvoiceCount = mid.invoices?.length ?? 0; + + try { + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 200, + }); + } catch { + // Insufficient balance rejection is expected + } + + await timeout(AUTO_TOPUP_WAIT_MS); + const after = await autumnV2_1.customers.get(customerId); + const afterInvoiceCount = after.invoices?.length ?? 0; + + const newInvoices = afterInvoiceCount - midInvoiceCount; + expect(newInvoices).toBeLessThanOrEqual(1); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup fm6: threshold 0 — never triggers")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-fm6", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-fm6", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // threshold=0 → check is `remainingBalance >= 0` → always true → never triggers + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 0, + quantity: 100, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(50); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-limits.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-limits.test.ts new file mode 100644 index 000000000..deb317263 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-limits.test.ts @@ -0,0 +1,384 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + PurchaseLimitInterval, + schemas, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; +import { eq } from "drizzle-orm"; +import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; +import { makeAutoTopupConfig } from "./utils/makeAutoTopupConfig"; + +const AUTO_TOPUP_WAIT_MS = 20000; + +const getAutoTopupLimitState = async ({ + ctx, + internalCustomerId, +}: { + ctx: Awaited>["ctx"]; + internalCustomerId: string; +}) => { + return await autoTopupLimitRepo.findByScope({ + ctx, + internalCustomerId, + featureId: TestFeature.Messages, + }); +}; + +test.concurrent(`${chalk.yellowBright("auto-topup limits: attempt window blocks top-up after limit")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-limits-window", + items: [oneOffItem], + }); + + const uniqueCustomerId = `auto-topup-attempt-limits`; + const { customerId, autumnV2_1, ctx, customer } = await initScenario({ + customerId: uniqueCustomerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { + interval: PurchaseLimitInterval.Month, + limit: 2, + }, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 260, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const expectedMid = new Decimal(300).sub(260).add(100).toNumber(); + expectBalanceCorrect({ + customer: mid, + featureId: TestFeature.Messages, + remaining: expectedMid, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const after2 = await autumnV2_1.customers.get(customerId); + const expectedAfter2 = new Decimal(140).sub(100).add(100).toNumber(); + expectBalanceCorrect({ + customer: after2, + featureId: TestFeature.Messages, + remaining: expectedAfter2, + }); + + expect(Boolean(customer?.internal_id)).toBe(true); + const stateBeforeReset = await getAutoTopupLimitState({ + ctx, + internalCustomerId: customer?.internal_id || "", + }); + expect(stateBeforeReset).toBeDefined(); + expect(stateBeforeReset?.purchase_count).toBe(2); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const after3 = await autumnV2_1.customers.get(customerId); + + expectBalanceCorrect({ + customer: after3, + featureId: TestFeature.Messages, + remaining: expectedAfter2 - 100, + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 3, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup limits: purchase window reset allows new top-up after expiry")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-limits-window", + items: [oneOffItem], + }); + + const uniqueCustomerId = `auto-topup-limits-window`; + const { customerId, autumnV2_1, ctx, customer } = await initScenario({ + customerId: uniqueCustomerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { + interval: PurchaseLimitInterval.Month, + limit: 2, + }, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 260, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const expectedMid = new Decimal(300).sub(260).add(100).toNumber(); + expectBalanceCorrect({ + customer: mid, + featureId: TestFeature.Messages, + remaining: expectedMid, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const after2 = await autumnV2_1.customers.get(customerId); + const expectedAfter2 = new Decimal(140).sub(100).add(100).toNumber(); + expectBalanceCorrect({ + customer: after2, + featureId: TestFeature.Messages, + remaining: expectedAfter2, + }); + + expect(Boolean(customer?.internal_id)).toBe(true); + const stateBeforeReset = await getAutoTopupLimitState({ + ctx, + internalCustomerId: customer?.internal_id || "", + }); + expect(stateBeforeReset).toBeDefined(); + expect(stateBeforeReset?.purchase_count).toBe(2); + + const forceExpireNow = Date.now(); + await ctx.db + .update(schemas.autoTopupLimits) + .set({ + purchase_window_ends_at: forceExpireNow - 1_000, + updated_at: forceExpireNow, + attempt_window_ends_at: forceExpireNow - 1_000, + }) + .where(eq(schemas.autoTopupLimits.id, stateBeforeReset?.id || "")); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const after3 = await autumnV2_1.customers.get(customerId); + const expectedAfter3 = new Decimal(140).sub(100).add(100).toNumber(); + expectBalanceCorrect({ + customer: after3, + featureId: TestFeature.Messages, + remaining: expectedAfter3, + }); + + const stateAfterReset = await getAutoTopupLimitState({ + ctx, + internalCustomerId: customer?.internal_id || "", + }); + expect(stateAfterReset).toBeDefined(); + expect(stateAfterReset?.purchase_count).toBe(1); + expect((stateAfterReset?.purchase_window_ends_at || 0) > Date.now()).toBe( + true, + ); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup limits: failed payment increments failed attempt count and voids invoice")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-limits-failed", + items: [oneOffItem], + }); + + const uniqueCustomerId = `auto-topup-limits-fail-${Date.now()}`; + const { customerId, autumnV2_1, ctx, customer } = await initScenario({ + customerId: uniqueCustomerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + s.removePaymentMethod(), + s.attachPaymentMethod({ type: "fail" }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 15, + }); + + expect(Boolean(customer?.internal_id)).toBe(true); + const stateAfterFail = await getAutoTopupLimitState({ + ctx, + internalCustomerId: customer?.internal_id || "", + }); + expect(stateAfterFail).toBeDefined(); + expect((stateAfterFail?.failed_attempt_count || 0) >= 1).toBe(true); + expect((stateAfterFail?.attempt_count || 0) >= 1).toBe(true); + expect((stateAfterFail?.last_failed_attempt_at || 0) > 0).toBe(true); + + const stripeCustomerId = customer?.processor?.id; + expect(Boolean(stripeCustomerId)).toBe(true); + const stripeInvoices = await ctx.stripeCli.invoices.list({ + customer: stripeCustomerId || "", + limit: 5, + }); + expect(stripeInvoices.data.length > 0).toBe(true); + expect(stripeInvoices.data[0].status).toBe("void"); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup edge: rate limit (max_purchases) blocks after limit")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-e3", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-e3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }), + ], + }); + + // Configure auto top-up with purchase_limit = 2 per month + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { + interval: PurchaseLimitInterval.Month, + limit: 1, + }, + }), + }); + + // Starting balance: 300 + + // Round 1: Track 260 → balance = 40 → top-up fires (purchase 1) → balance = 140 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 260, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const after1 = await autumnV2_1.customers.get(customerId); + const balance1 = after1.balances[TestFeature.Messages].remaining; + const expected1 = new Decimal(300).sub(260).add(100).toNumber(); + expect(balance1).toBe(expected1); // 140 + + // Round 2: Track 100 → balance = 40 → top-up fires (purchase 2) → balance = 140 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const after2 = await autumnV2_1.customers.get(customerId); + const balance2 = after2.balances[TestFeature.Messages].remaining; + expect(balance2).toBe(balance1 - 100); // 40 + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-race-condition.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-race-condition.test.ts new file mode 100644 index 000000000..6649c9956 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-race-condition.test.ts @@ -0,0 +1,270 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5, CustomerBillingControls } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; + +/** SQS auto top-up wait + sync settle time */ +const AUTO_TOPUP_WAIT_MS = 20000; +const SYNC_SETTLE_MS = 5000; + +const makeAutoTopupConfig = ({ + threshold = 20, + quantity = 100, +}: { + threshold?: number; + quantity?: number; +} = {}): CustomerBillingControls => ({ + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold, + quantity, + }, + ], +}); + +test.concurrent(`${chalk.yellowBright("auto-topup race: concurrent track during auto top-up — Redis and Postgres agree")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-race1", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-race1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Configure auto top-up: threshold=20, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Verify starting balance + const before = await autumnV2_1.customers.get(customerId); + expect(before.balances[TestFeature.Messages].remaining).toBe(100); + + // Track 85 → balance = 15 (triggers auto top-up via SQS) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + // Immediately track 5 more — concurrent with the in-flight auto top-up + // This exercises the race between: + // 1. Deduction sync (Redis → Postgres, 1s delay) + // 2. Auto top-up (CusEntService.increment + Redis increment with cache_version bump) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + + // Wait for auto top-up processing + sync settlement + await timeout(AUTO_TOPUP_WAIT_MS); + + // Expected balance: 100 - 85 - 5 + 100 = 110 + const expectedBalance = new Decimal(100).sub(85).sub(5).add(100).toNumber(); + + // 1. Check cached balance (Redis-backed) + const cached = await autumnV2_1.customers.get(customerId); + expect(cached.balances[TestFeature.Messages].remaining).toBe(expectedBalance); + + // 2. Wait for sync to fully settle, then check DB balance + await timeout(SYNC_SETTLE_MS); + + const fromDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expect(fromDb.balances[TestFeature.Messages].remaining).toBe(expectedBalance); + + // 3. Verify billing_controls survived + expect(cached.billing_controls?.auto_topups).toBeDefined(); + expect(cached.billing_controls?.auto_topups?.[0]?.feature_id).toBe( + TestFeature.Messages, + ); + expect(cached.billing_controls?.auto_topups?.[0]?.enabled).toBe(true); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup race: rapid sequential tracks — deductions interleaved with top-up")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-race2", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-race2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + // Configure auto top-up: threshold=30, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 30, + quantity: 100, + }), + }); + + // Starting balance: 200 + // Track 175 → balance = 25 (triggers auto top-up) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 175, + }); + + // Fire 3 more small tracks while auto top-up is in-flight + // Each deduction hits Redis immediately, but sync to Postgres is delayed + await Promise.all([ + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }), + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }), + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 4, + }), + ]); + + // Total deducted: 175 + 3 + 3 + 4 = 185 + // After top-up: 200 - 185 + 100 = 115 + await timeout(AUTO_TOPUP_WAIT_MS); + + const expectedBalance = new Decimal(200).sub(185).add(100).toNumber(); + + const cached = await autumnV2_1.customers.get(customerId); + expect(cached.balances[TestFeature.Messages].remaining).toBe(expectedBalance); + + // Verify DB matches after sync settles + await timeout(SYNC_SETTLE_MS); + + const fromDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expect(fromDb.balances[TestFeature.Messages].remaining).toBe(expectedBalance); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup race: top-up then immediate re-drain — second top-up fires correctly")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-race3", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-race3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Configure auto top-up: threshold=20, quantity=100 + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // Round 1: Track 85 → balance = 15 → top-up fires → balance = 115 + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const mid = await autumnV2_1.customers.get(customerId); + const expectedMid = new Decimal(100).sub(85).add(100).toNumber(); + expect(mid.balances[TestFeature.Messages].remaining).toBe(expectedMid); + + // Round 2: Track 100 → balance = 15 → second top-up fires + // Then immediately track 5 more (concurrent with second top-up) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Expected: 115 - 100 - 5 + 100 = 110 + const expectedFinal = new Decimal(expectedMid) + .sub(100) + .sub(5) + .add(100) + .toNumber(); + + const after = await autumnV2_1.customers.get(customerId); + expect(after.balances[TestFeature.Messages].remaining).toBe(expectedFinal); + + // Verify DB after sync + await timeout(SYNC_SETTLE_MS); + + const fromDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expect(fromDb.balances[TestFeature.Messages].remaining).toBe(expectedFinal); +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-trigger.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-trigger.test.ts new file mode 100644 index 000000000..63fecd7f0 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-trigger.test.ts @@ -0,0 +1,208 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { makeAutoTopupConfig } from "@tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** Wait time for SQS auto top-up processing */ +const AUTO_TOPUP_WAIT_MS = 40000; + +test.concurrent(`${chalk.yellowBright("auto-topup trigger 1: when balance is 0 and check is called (when auto top up config is set), trigger fires")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-trigger1", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-trigger1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 90, + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 100, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 110, + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: prod.id, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup trigger 2: check with send_event=true does not double-trigger")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-trigger2", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-trigger2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // check with send_event=true deducts AND triggers auto top-up check + // Both the check path (getCheckData) and the track path (deduction) + // call triggerAutoTopUp — burst suppression NX key should prevent double-fire + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + send_event: true, + required_balance: 85, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + + // 100 - 85 = 15, then exactly ONE top-up of 100 → 115 + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 115, + }); + + // Exactly 2 invoices: initial attach + one top-up (not two) + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: prod.id, + }); +}); + +test.concurrent(`${chalk.yellowBright("auto-topup trigger 3: track depletes, then check with send_event=true does not double-trigger")}`, async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const prod = products.base({ + id: "topup-trigger3", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-trigger3", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Track 85 → balance = 15 (below future threshold, but no config yet) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 85, + }); + + // Now set auto top-up config + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 20, + quantity: 100, + }), + }); + + // check with send_event=true deducts 2 more → balance = 13 + // Both the check path and the deduction path call triggerAutoTopUp + // Burst suppression should ensure only ONE top-up fires → balance = 13 + 100 = 113 + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + send_event: true, + required_balance: 2, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + const after = await autumnV2_1.customers.get(customerId); + + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 113, // 15 - 2 + 100 + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: prod.id, + }); +}); diff --git a/server/tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.ts b/server/tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.ts new file mode 100644 index 000000000..d279156a6 --- /dev/null +++ b/server/tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.ts @@ -0,0 +1,27 @@ +import type { + CustomerBillingControlsInput, + PurchaseLimitInterval, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; + +export const makeAutoTopupConfig = ({ + threshold = 20, + quantity = 100, + enabled = true, + purchaseLimit, +}: { + threshold?: number; + quantity?: number; + enabled?: boolean; + purchaseLimit?: { interval: PurchaseLimitInterval; limit: number }; +} = {}): CustomerBillingControlsInput => ({ + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled, + threshold, + quantity, + ...(purchaseLimit ? { purchase_limit: purchaseLimit } : {}), + }, + ], +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-prepaid-granted.test.ts b/server/tests/integration/balances/update/balance/update-balance-prepaid-granted.test.ts new file mode 100644 index 000000000..3404c8ea0 --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-prepaid-granted.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomer, computeGrantedBalanceInput } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════ +// Regression: BalanceEditSheet granted_balance computation with prepaid +// +// When a customer has a prepaid component AND usage > 0, the frontend +// "set" mode must compute granted_balance correctly by subtracting the +// actual prepaid allowance — not a value derived from form defaults. +// +// The buggy formula: prepaid = defaultGPB - defaultBalance (= usage) +// The correct value: prepaid = purchased_balance (actual prepaid qty) +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-prepaid-granted1: granted_balance correct with prepaid + usage")}`, async () => { + // Free product: 100 included messages (granted_balance = 100) + const freeMessages = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [freeMessages] }); + + // Prepaid add-on: billingUnits=1, quantity=200 → 200 purchased + const prepaidMessages = items.prepaidMessages({ + includedUsage: 0, + price: 1, + billingUnits: 1, + }); + const prepaidProd = products.base({ + id: "prepaid", + items: [prepaidMessages], + isAddOn: true, + }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-prepaid-granted1", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [freeProd, prepaidProd] }), + ], + actions: [ + s.attach({ productId: freeProd.id }), + s.attach({ + productId: prepaidProd.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 200, + }, + ], + }), + ], + }); + + // Wait for Stripe webhooks to process + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Verify initial: granted=100, purchased=200, current=300, usage=0 + const initial = await autumnV2.customers.get(customerId); + expect(initial.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + purchased_balance: 200, + current_balance: 300, + usage: 0, + }); + + // Track 30 usage → current drops to 270 + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + const afterTrack = await autumnV2.customers.get(customerId); + const bal = afterTrack.balances[TestFeature.Messages]; + expect(bal).toMatchObject({ + granted_balance: 100, + purchased_balance: 200, + current_balance: 270, + usage: 30, + }); + + // ── Simulate BalanceEditSheet "set" mode submission ── + // Form defaults mirror the API state: + // defaultGPB = granted + purchased = 300 + // defaultBalance = current_balance = 270 + // prepaidAllowance = purchased_balance = 200 + // + // User increases GPB from 300 → 320 (wants +20 on granted) + const defaultGPB = bal.granted_balance + bal.purchased_balance; // 300 + const defaultBalance = bal.current_balance; // 270 + const prepaidAllowance = bal.purchased_balance; // 200 + const newGPB = 320; + + const grantedBalanceInput = computeGrantedBalanceInput({ + newGPB, + defaultGPB, + defaultBalance, + prepaidAllowance, + }); + + // Call the balance update API as the frontend would + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: bal.current_balance, + granted_balance: grantedBalanceInput, + }); + + // Expected: granted_balance = 120 (100 original + 20 increase) + // Buggy: grantedBalanceInput = 320 - (300 - 270) = 290 → FAILS + // Fixed: grantedBalanceInput = 320 - 200 = 120 → PASSES + const afterUpdate = await autumnV2.customers.get(customerId); + expect(afterUpdate.balances[TestFeature.Messages].granted_balance).toBe(120); + + // Verify DB sync + const afterUpdateDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(afterUpdateDb.balances[TestFeature.Messages].granted_balance).toBe( + 120, + ); +}); diff --git a/server/tests/integration/utils/expectBalanceCorrect.ts b/server/tests/integration/utils/expectBalanceCorrect.ts new file mode 100644 index 000000000..e338a56a5 --- /dev/null +++ b/server/tests/integration/utils/expectBalanceCorrect.ts @@ -0,0 +1,15 @@ +import { expect } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; + +export const expectBalanceCorrect = ({ + customer, + featureId, + remaining, +}: { + customer: ApiCustomerV5; + featureId: string; + remaining: number; +}) => { + expect(customer.balances[featureId]).toBeDefined(); + expect(customer.balances[featureId].remaining).toBe(remaining); +}; diff --git a/server/tests/integration/utils/expectCustomerProductOptions.ts b/server/tests/integration/utils/expectCustomerProductOptions.ts new file mode 100644 index 000000000..f022a8e4f --- /dev/null +++ b/server/tests/integration/utils/expectCustomerProductOptions.ts @@ -0,0 +1,33 @@ +import { expect } from "bun:test"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +export const expectCustomerProductOptions = async ({ + ctx, + customerId, + productId, + featureId, + quantity, +}: { + ctx: TestContext; + customerId: string; + productId: string; + featureId: string; + quantity: number; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + const cusProduct = fullCustomer.customer_products.find( + (customerProduct) => customerProduct.product.id === productId, + ); + expect(cusProduct).toBeDefined(); + + const featureOption = cusProduct?.options.find( + (option) => option.feature_id === featureId, + ); + expect(featureOption).toBeDefined(); + expect(featureOption?.quantity).toBe(quantity); +}; diff --git a/server/tests/scenarios/attach/one-off-prepaid-auto-topup-scenario.test.ts b/server/tests/scenarios/attach/one-off-prepaid-auto-topup-scenario.test.ts new file mode 100644 index 000000000..1603925b8 --- /dev/null +++ b/server/tests/scenarios/attach/one-off-prepaid-auto-topup-scenario.test.ts @@ -0,0 +1,63 @@ +import { test } from "bun:test"; +import type { CustomerBillingControls } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * One-Off Prepaid Auto Top-Up Scenario + * + * Sets up a one-off prepaid add-on product with auto top-up configured. + * Auto top-up requires: one-off price, prepaid usage model, and a payment method. + * + * Setup: + * - One-off add-on: $10 per 100 message credits (no recurring charges) + * - Customer with payment method, product attached with 100 initial credits + * - Auto top-up: threshold=20, quantity=100 (triggers when balance drops below 20) + */ + +test(`${chalk.yellowBright("one-off-prepaid-auto-topup: one-off prepaid product with auto top-up attached")}`, async () => { + const customerId = "one-off-prepaid-auto-topup"; + + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const oneOffAddOn = products.oneOffAddOn({ + id: "topup-addon", + items: [oneOffMessagesItem], + }); + + const { autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffAddOn] }), + ], + actions: [ + s.attach({ + productId: oneOffAddOn.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + const billingControls: CustomerBillingControls = { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 20, + quantity: 100, + }, + ], + }; + + await autumnV2_1.customers.update(customerId, { + billing_controls: billingControls, + }); +}); diff --git a/shared/api/common/customerData.ts b/shared/api/common/customerData.ts index 558d351ad..586328720 100644 --- a/shared/api/common/customerData.ts +++ b/shared/api/common/customerData.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { CustomerBillingControlsSchema } from "../../models/cusModels/billingControlModels"; import { ExternalProcessorsSchema } from "../../models/genModels/processorSchemas"; // for internal use only @@ -49,6 +50,10 @@ export const CustomerDataSchema = z description: "Whether to send email receipts to this customer", }), + billing_controls: CustomerBillingControlsSchema.optional().meta({ + description: "Billing controls for the customer (auto top-ups, etc.)", + }), + internal_options: CreateCustomerInternalOptionsSchema.optional().meta({ internal: true, }), diff --git a/shared/api/customers/apiCustomerV5.ts b/shared/api/customers/apiCustomerV5.ts index 5b16aae7b..fb0766faa 100644 --- a/shared/api/customers/apiCustomerV5.ts +++ b/shared/api/customers/apiCustomerV5.ts @@ -17,6 +17,9 @@ export const API_CUSTOMER_V5_EXAMPLE = { env: "sandbox", metadata: {}, sendEmailReceipts: false, + billingControls: { + autoTopups: [], + }, subscriptions: [ { planId: "pro_plan", diff --git a/shared/api/customers/baseApiCustomer.ts b/shared/api/customers/baseApiCustomer.ts index b34cf2128..130eda24e 100644 --- a/shared/api/customers/baseApiCustomer.ts +++ b/shared/api/customers/baseApiCustomer.ts @@ -1,3 +1,4 @@ +import { CustomerBillingControlsSchema } from "@models/cusModels/billingControlModels"; import { AppEnv } from "@models/genModels/genEnums"; import { z } from "zod/v4"; @@ -33,6 +34,9 @@ export const BaseApiCustomerSchema = z.object({ send_email_receipts: z.boolean().meta({ description: "Whether to send email receipts to the customer.", }), + billing_controls: CustomerBillingControlsSchema.meta({ + description: "Billing controls for the customer (auto top-ups, etc.)", + }), }); export type BaseApiCustomer = z.infer; diff --git a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts index 1ac459cff..474e270c2 100644 --- a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts +++ b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts @@ -1,8 +1,5 @@ import { Decimal } from "decimal.js"; -import type { - ApiBalanceBreakdownV1, - ApiBalanceV1, -} from "../../apiBalanceV1"; +import type { ApiBalanceBreakdownV1, ApiBalanceV1 } from "../../apiBalanceV1"; import { apiBalanceBreakdownV1ToOverage, apiBalanceV1ToOverage, diff --git a/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts b/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts index f40cd8cc6..6b28c5bfd 100644 --- a/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts +++ b/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts @@ -1,4 +1,4 @@ -import { type ApiProductItem } from "@api/models"; +import type { ApiProductItem } from "@api/models"; import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems"; import { ApiVersion } from "@api/versionUtils/ApiVersion"; import { diff --git a/shared/db/schema.ts b/shared/db/schema.ts index 0184d0d5f..74c75cb29 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -5,9 +5,9 @@ import { actions } from "../models/analyticsModels/actionTable.js"; import { chatResults } from "../models/chatResultModels/chatResultTable.js"; import { checkoutsRelations } from "../models/checkouts/checkoutRelations.js"; import { checkouts } from "../models/checkouts/checkoutTable.js"; +import { autoTopupLimitStates } from "../models/cusModels/billingControls/autoTopupLimitTable.js"; // Customer Relations import { customersRelations } from "../models/cusModels/cusRelations.js"; - // Customer Tables import { customers } from "../models/cusModels/cusTable.js"; import { entitiesRelations } from "../models/cusModels/entityModels/entityRelations.js"; @@ -99,6 +99,7 @@ export { invoices, invoiceLineItems, customers, + autoTopupLimitStates as autoTopupLimits, entities, apiKeys, metadata, diff --git a/shared/index.ts b/shared/index.ts index d743af93d..d5e5b21f9 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -47,6 +47,8 @@ export * from "./models/chatResultModels/chatResultFeature"; export * from "./models/chatResultModels/chatResultTable"; export * from "./models/checkModels/checkPreviewModels"; // 8. Customer Models +export * from "./models/cusModels/billingControlModels"; +export * from "./models/cusModels/billingControls/autoTopupLimitTable"; export * from "./models/cusModels/cusModels"; // Processor Models export * from "./models/processorModels/processorModels"; @@ -97,6 +99,8 @@ export * from "./models/attachModels/attachFunctionResponse"; export * from "./models/billingModels/index"; // Checkout Models export * from "./models/checkouts/index"; +// Billing Controls +export * from "./models/cusModels/index"; export * from "./models/cusProductModels/cusPriceModels/customerPriceWithCustomerProduct"; // 2. Feature Models export * from "./models/featureModels/featureTable"; diff --git a/shared/models/billingModels/cusProductActions.ts b/shared/models/billingModels/cusProductActions.ts deleted file mode 100644 index 756d510d2..000000000 --- a/shared/models/billingModels/cusProductActions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import z from "zod/v4"; - -import { - EnrichedNewProductActionSchema, - type NewProductAction, - NewProductActionSchema, -} from "./newProductAction"; -import { - type OngoingCusProductAction, - OngoingCusProductActionSchema, -} from "./ongoingCusProductAction"; -import { - type ScheduledCusProductAction, - ScheduledCusProductActionSchema, -} from "./scheduledCusProductAction"; - -export interface CusProductActions { - ongoingCusProductAction?: OngoingCusProductAction; - scheduledCusProductAction?: ScheduledCusProductAction; - newProductActions: NewProductAction[]; -} - -export const CusProductActionsSchema: z.ZodObject = z.object({ - ongoingCusProductAction: OngoingCusProductActionSchema, - scheduledCusProductAction: ScheduledCusProductActionSchema, - newProductActions: z.array(NewProductActionSchema), -}); - -export const EnrichedCusProductActionsSchema: z.ZodObject = - CusProductActionsSchema.extend({ - ongoingCusProductAction: OngoingCusProductActionSchema, - scheduledCusProductAction: ScheduledCusProductActionSchema, - newProductActions: z.array(EnrichedNewProductActionSchema), - }); diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index ab0315e21..64b1a4700 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -9,7 +9,7 @@ import { FreeTrialSchema, FullCusProductSchema, FullCustomerEntitlementSchema, - InvoiceSchema, + type InsertInvoice, PriceSchema, ReplaceableSchema, SubscriptionSchema, @@ -39,6 +39,7 @@ export const UpdateCustomerEntitlementSchema = z.object({ }); export const AutumnBillingPlanSchema = z.object({ + customerId: z.string(), insertCustomerProducts: z.array(FullCusProductSchema), updateCustomerProduct: z @@ -79,7 +80,7 @@ export const AutumnBillingPlanSchema = z.object({ // Upsert operations (populated during webhook handling, e.g., checkout.session.completed) upsertSubscription: SubscriptionSchema.optional(), - upsertInvoice: InvoiceSchema.optional(), + upsertInvoice: z.custom().optional(), }); export type AutumnBillingPlan = z.infer; diff --git a/shared/models/cusModels/billingControlModels.ts b/shared/models/cusModels/billingControlModels.ts new file mode 100644 index 000000000..28bd659cf --- /dev/null +++ b/shared/models/cusModels/billingControlModels.ts @@ -0,0 +1,32 @@ +import { z } from "zod/v4"; +import { PurchaseLimitIntervalEnum } from "./billingControls/purchaseLimitInterval.js"; + +export const AutoTopupPurchaseLimitSchema = z.object({ + interval: PurchaseLimitIntervalEnum, + interval_count: z.number().min(1).default(1), + limit: z.number().min(1), +}); + +export const AutoTopupSchema = z.object({ + feature_id: z.string(), + enabled: z.boolean().default(false), + threshold: z.number().min(0), + quantity: z.number().min(1), + purchase_limit: AutoTopupPurchaseLimitSchema.optional(), +}); + +export const CustomerBillingControlsSchema = z.object({ + auto_topups: z.array(AutoTopupSchema).optional(), +}); + +export type AutoTopupPurchaseLimit = z.infer< + typeof AutoTopupPurchaseLimitSchema +>; +export type AutoTopup = z.infer; +export type CustomerBillingControls = z.infer< + typeof CustomerBillingControlsSchema +>; + +export type CustomerBillingControlsInput = z.input< + typeof CustomerBillingControlsSchema +>; diff --git a/shared/models/cusModels/billingControls/autoTopupLimitTable.ts b/shared/models/cusModels/billingControls/autoTopupLimitTable.ts new file mode 100644 index 000000000..981c0c91e --- /dev/null +++ b/shared/models/cusModels/billingControls/autoTopupLimitTable.ts @@ -0,0 +1,58 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { + foreignKey, + numeric, + pgTable, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { sqlNow } from "../../../db/utils"; +import { organizations } from "../../orgModels/orgTable.js"; +import { customers } from "../cusTable.js"; + +export const autoTopupLimitStates = pgTable( + "auto_topup_limit_states", + { + id: text().primaryKey().notNull(), + org_id: text().notNull(), + env: text().notNull(), + internal_customer_id: text().notNull(), + customer_id: text().notNull(), + feature_id: text().notNull(), + + purchase_window_ends_at: numeric({ mode: "number" }).notNull(), + purchase_count: numeric({ mode: "number" }).notNull().default(0), + + attempt_window_ends_at: numeric({ mode: "number" }).notNull(), + attempt_count: numeric({ mode: "number" }).notNull().default(0), + + failed_attempt_window_ends_at: numeric({ mode: "number" }).notNull(), + failed_attempt_count: numeric({ mode: "number" }).notNull().default(0), + + last_attempt_at: numeric({ mode: "number" }), + last_failed_attempt_at: numeric({ mode: "number" }), + + created_at: numeric({ mode: "number" }).notNull().default(sqlNow), + updated_at: numeric({ mode: "number" }).notNull().default(sqlNow), + }, + (table) => [ + uniqueIndex( + "auto_topup_limits_org_env_internal_customer_feature_unique", + ).on(table.org_id, table.env, table.internal_customer_id, table.feature_id), + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "auto_topup_limits_org_id_fkey", + }).onDelete("cascade"), + foreignKey({ + columns: [table.internal_customer_id], + foreignColumns: [customers.internal_id], + name: "auto_topup_limits_internal_customer_id_fkey", + }).onDelete("cascade"), + ], +); + +export type AutoTopupLimitState = InferSelectModel; +export type InsertAutoTopupLimitState = InferInsertModel< + typeof autoTopupLimitStates +>; diff --git a/shared/models/cusModels/billingControls/purchaseLimitInterval.ts b/shared/models/cusModels/billingControls/purchaseLimitInterval.ts new file mode 100644 index 000000000..5f1404938 --- /dev/null +++ b/shared/models/cusModels/billingControls/purchaseLimitInterval.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; + +export enum PurchaseLimitInterval { + Hour = "hour", + Day = "day", + Week = "week", + Month = "month", +} + +export const PurchaseLimitIntervalEnum = z.enum(PurchaseLimitInterval); diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index 836834ee9..89f2f23cf 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { AppEnv } from "../genModels/genEnums.js"; import { ExternalProcessorsSchema } from "../genModels/processorSchemas.js"; +import { AutoTopupSchema } from "./billingControlModels.js"; export const CustomerSchema = z.object({ id: z.string().nullish(), // given by user @@ -17,6 +18,7 @@ export const CustomerSchema = z.object({ processors: ExternalProcessorsSchema.nullish(), metadata: z.record(z.any(), z.any()).nullish().default({}), send_email_receipts: z.boolean().default(false), + auto_topups: z.array(AutoTopupSchema).nullish(), }); export type Customer = z.infer; diff --git a/shared/models/cusModels/cusTable.ts b/shared/models/cusModels/cusTable.ts index 104bb498f..0d0798f2d 100644 --- a/shared/models/cusModels/cusTable.ts +++ b/shared/models/cusModels/cusTable.ts @@ -12,6 +12,7 @@ import { import { collatePgColumn } from "../../db/utils.js"; import type { ExternalProcessors } from "../genModels/processorSchemas.js"; import { organizations } from "../orgModels/orgTable.js"; +import type { AutoTopup } from "./billingControlModels.js"; export type CustomerProcessor = { type: "stripe"; @@ -35,6 +36,7 @@ export const customers = pgTable( .$type() .default({} as ExternalProcessors), send_email_receipts: boolean("send_email_receipts").default(false), + auto_topups: jsonb().$type(), }, (table) => [ unique("cus_id_constraint").on(table.org_id, table.id, table.env), diff --git a/shared/models/cusModels/index.ts b/shared/models/cusModels/index.ts new file mode 100644 index 000000000..9328a0336 --- /dev/null +++ b/shared/models/cusModels/index.ts @@ -0,0 +1,2 @@ +export * from "./billingControlModels.js"; +export * from "./billingControls/purchaseLimitInterval.js"; diff --git a/shared/utils/cusEntUtils/balanceUtils/computeGrantedBalanceInput.ts b/shared/utils/cusEntUtils/balanceUtils/computeGrantedBalanceInput.ts new file mode 100644 index 000000000..d749493be --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/computeGrantedBalanceInput.ts @@ -0,0 +1,19 @@ +/** + * Computes the granted_balance value to send to the balance update API + * when the BalanceEditSheet "set" mode submits. + * + * The form displays grantedAndPurchasedBalance (GPB) as an editable field. + * To derive the actual granted_balance, we subtract the prepaid allowance + * from the user's new GPB value. + */ +export function computeGrantedBalanceInput({ + newGPB, + prepaidAllowance, +}: { + newGPB: number; + defaultGPB: number; + defaultBalance: number; + prepaidAllowance: number; +}): number { + return newGPB - prepaidAllowance; +} diff --git a/shared/utils/cusEntUtils/index.ts b/shared/utils/cusEntUtils/index.ts index 1f3915724..6278f2a6b 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -2,6 +2,7 @@ // Balance utils barrel export * from "./balanceUtils"; +export * from "./balanceUtils/computeGrantedBalanceInput"; export * from "./balanceUtils/cusEntsToBalance"; export * from "./balanceUtils/cusEntsToCurrentBalance"; export * from "./balanceUtils/cusEntsToPrepaidInvoiceOverage"; diff --git a/shared/utils/intervalUtils.ts b/shared/utils/intervalUtils.ts index 7130ddc97..d11bde011 100644 --- a/shared/utils/intervalUtils.ts +++ b/shared/utils/intervalUtils.ts @@ -52,6 +52,24 @@ export const entIntervalToValue = ( return new Decimal(baseValue).mul(intervalCount ?? 1); }; +/** Convert a BillingInterval to its approximate duration in seconds. */ +export const billingIntervalToSeconds = ({ + interval, +}: { + interval: BillingInterval; +}): number => { + const intervalToSeconds: Record = { + [BillingInterval.OneOff]: 0, + [BillingInterval.Week]: 7 * 24 * 60 * 60, + [BillingInterval.Month]: 30 * 24 * 60 * 60, + [BillingInterval.Quarter]: 90 * 24 * 60 * 60, + [BillingInterval.SemiAnnual]: 180 * 24 * 60 * 60, + [BillingInterval.Year]: 365 * 24 * 60 * 60, + }; + + return intervalToSeconds[interval] ?? 30 * 24 * 60 * 60; +}; + export const entIntervalsSame = ({ intervalA, intervalB, diff --git a/vite/src/components/general/form/fields/area-checkbox-field.tsx b/vite/src/components/general/form/fields/area-checkbox-field.tsx new file mode 100644 index 000000000..c5e8637ac --- /dev/null +++ b/vite/src/components/general/form/fields/area-checkbox-field.tsx @@ -0,0 +1,31 @@ +import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox"; +import { useFieldContext } from "@/hooks/form/form-context"; + +export function AreaCheckboxField({ + title, + description, + disabled, + disabledReason, + children, +}: { + title: string; + description?: string; + disabled?: boolean; + disabledReason?: string; + children?: React.ReactNode; +}) { + const field = useFieldContext(); + + return ( + field.handleChange(checked)} + disabled={disabled} + disabledReason={disabledReason} + > + {children} + + ); +} diff --git a/vite/src/components/general/form/fields/number-field.tsx b/vite/src/components/general/form/fields/number-field.tsx index d735d5ba5..7ba5f1e9e 100644 --- a/vite/src/components/general/form/fields/number-field.tsx +++ b/vite/src/components/general/form/fields/number-field.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; export function NumberField({ label, + description, placeholder, min, max, @@ -13,8 +14,10 @@ export function NumberField({ inputClassName, hideFieldInfo, disabled, + float, }: { label: string; + description?: string; placeholder?: string; min?: number; max?: number; @@ -22,6 +25,8 @@ export function NumberField({ inputClassName?: string; hideFieldInfo?: boolean; disabled?: boolean; + /** Use parseFloat instead of parseInt */ + float?: boolean; }) { const field = useFieldContext(); @@ -31,7 +36,9 @@ export function NumberField({ field.handleChange(null); return; } - const numValue = Number.parseInt(value, 10); + const numValue = float + ? Number.parseFloat(value) + : Number.parseInt(value, 10); if (!Number.isNaN(numValue)) { if (max !== undefined && numValue > max) { field.handleChange(max); @@ -46,6 +53,7 @@ export function NumberField({ return (
{label && } + {description &&

{description}

} ; + description?: string; placeholder: string; } & React.ComponentProps<"input">) => { const inputId = useId(); @@ -16,6 +18,7 @@ export const LabelInput = ({ return (
{label}
+ {description &&

{description}

}
); diff --git a/vite/src/hooks/form/form.ts b/vite/src/hooks/form/form.ts index e27f7d105..650808d5a 100644 --- a/vite/src/hooks/form/form.ts +++ b/vite/src/hooks/form/form.ts @@ -1,5 +1,6 @@ import { createFormHook } from "@tanstack/react-form"; import { SubmitButton } from "@/components/general/form/buttons/submit-button"; +import { AreaCheckboxField } from "@/components/general/form/fields/area-checkbox-field"; import { CheckboxField } from "@/components/general/form/fields/checkbox-field"; import { NumberField } from "@/components/general/form/fields/number-field"; import { QuantityField } from "@/components/general/form/fields/quantity-field"; @@ -16,6 +17,7 @@ export const { useAppForm } = createFormHook({ QuantityField, CheckboxField, NumberField, + AreaCheckboxField, }, formComponents: { SubmitButton, diff --git a/vite/src/views/customers2/components/sheets/AutoTopUpSection.tsx b/vite/src/views/customers2/components/sheets/AutoTopUpSection.tsx new file mode 100644 index 000000000..6ba4422fa --- /dev/null +++ b/vite/src/views/customers2/components/sheets/AutoTopUpSection.tsx @@ -0,0 +1,122 @@ +import { BillingInterval } from "@autumn/shared"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/v2/selects/Select"; +import type { BalanceEditFormInstance } from "./useBalanceEditForm"; + +const RATE_LIMIT_INTERVALS = [ + { value: BillingInterval.Week, label: "Week" }, + { value: BillingInterval.Month, label: "Month" }, + { value: BillingInterval.Quarter, label: "Quarter" }, + { value: BillingInterval.SemiAnnual, label: "Semi-Annual" }, + { value: BillingInterval.Year, label: "Year" }, +]; + +export function AutoTopUpSection({ form }: { form: BalanceEditFormInstance }) { + return ( +
+ + {(field) => ( + + )} + + + + {(enabledField) => + enabledField.state.value && ( + <> +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + +
+ + + {(field) => ( + + )} + + + + {(maxField) => + maxField.state.value && ( +
+ + {(field) => ( +
+
+ Interval +
+

+ Rate limit reset period +

+ +
+ )} +
+ + {(field) => ( + + )} + +
+ ) + } +
+ + ) + } +
+
+ ); +} diff --git a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx index 720fc4d4e..abb0f1590 100644 --- a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx @@ -1,16 +1,17 @@ import { - cusEntsToBalance, - cusEntsToGrantedBalance, - cusEntsToPrepaidQuantity, + type AutoTopup, + computeGrantedBalanceInput, type Entity, type FullCusProduct, type FullCustomerEntitlement, type FullCustomerPrice, + isOneOffPrice, + isPrepaidPrice, isUnlimitedCusEnt, - nullish, numberWithCommas, } from "@autumn/shared"; -import { useEffect, useMemo, useState } from "react"; +import { useStore } from "@tanstack/react-form"; +import { useState } from "react"; import { toast } from "sonner"; import { DateInputUnix } from "@/components/general/DateInputUnix"; import { Button } from "@/components/v2/buttons/Button"; @@ -21,162 +22,28 @@ import { LabelInput } from "@/components/v2/inputs/LabelInput"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { CusService } from "@/services/customers/CusService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { getBackendErr, notNullish } from "@/utils/genUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; import { useCustomerContext } from "../../customer/CustomerContext"; +import { AutoTopUpSection } from "./AutoTopUpSection"; import { BalanceEditPreviews } from "./BalanceEditPreviews"; import { GrantedBalancePopover } from "./GrantedBalancePopover"; +import { + type BalanceEditFormInstance, + useBalanceEditForm, +} from "./useBalanceEditForm"; + +/* ─── Outer Shell ─── */ export function BalanceEditSheet() { - const { customer, refetch } = useCusQuery(); + const { customer } = useCusQuery(); const { entityId } = useCustomerContext(); - const { - featureId, - originalEntitlements, - selectedCusEntId, - closeSheet: closeBalanceSheet, - } = useCustomerBalanceSheetStore(); - const closeSheet = useSheetStore((s) => s.closeSheet); - - const axiosInstance = useAxiosInstance(); - const [updateLoading, setUpdateLoading] = useState(false); - const [mode, setMode] = useState<"set" | "add">("set"); - const [addValue, setAddValue] = useState(""); - - const [grantedBalanceChanged, setGrantedBalanceChanged] = useState(false); - - // Get the selected entitlement - const selectedCusEnt = originalEntitlements.find( - (ent) => ent.id === selectedCusEntId, - ); - - const prepaidAllowance = useMemo(() => { - if (!selectedCusEnt) return 0; - return cusEntsToPrepaidQuantity({ - cusEnts: [selectedCusEnt], - sumAcrossEntities: nullish(entityId), - }); - }, [selectedCusEnt]); - - // Get the initial fields for the selected entitlement - const initialFields = useMemo(() => { - if (!selectedCusEnt) { - return { - balance: null as number | null, - next_reset_at: null as number | null, - }; - } - - const balance = cusEntsToBalance({ - cusEnts: [selectedCusEnt], - entityId: entityId ?? undefined, - withRollovers: true, - }); - - const grantedBalance = cusEntsToGrantedBalance({ - cusEnts: [selectedCusEnt], - entityId: entityId ?? undefined, - }); - - const grantedAndPurchasedBalance = grantedBalance + prepaidAllowance; - - return { - balance: balance !== null ? balance : null, - grantedAndPurchasedBalance: - grantedAndPurchasedBalance !== null ? grantedAndPurchasedBalance : null, - next_reset_at: selectedCusEnt.next_reset_at, - }; - }, [selectedCusEnt, entityId]); - - const [updateFields, setUpdateFields] = useState(initialFields); - - // Reset fields when selected entitlement changes - useEffect(() => { - setUpdateFields(initialFields); - }, [initialFields]); - - const getCusProduct = (cusEnt: FullCustomerEntitlement) => { - const cusProduct = customer?.customer_products.find( - (cp: FullCusProduct) => cp.id === cusEnt.customer_product_id, - ); - return cusProduct; - }; - - const handleClose = () => { - closeBalanceSheet(); - closeSheet(); - }; - - const handleUpdateCusEntitlement = async ( - cusEnt: FullCustomerEntitlement, - ) => { - const balanceInt = parseFloat(String(updateFields.balance)); - const grantedAndPurchasedBalanceFloat = parseFloat( - String(updateFields.grantedAndPurchasedBalance), - ); - if (Number.isNaN(balanceInt)) { - toast.error("Please enter a valid balance"); - return; - } - - const grantedBalanceInput = - grantedAndPurchasedBalanceFloat - prepaidAllowance; - - const cusProduct = getCusProduct(cusEnt); - const cusPrice = cusProduct?.customer_prices.find( - (cp: FullCustomerPrice) => - cp.price.entitlement_id === cusEnt.entitlement.id, - ); - - if (cusPrice && updateFields.next_reset_at !== cusEnt.next_reset_at) { - toast.error("Not allowed to change reset date for paid features"); - return; - } - - setUpdateLoading(true); - - try { - await axiosInstance.post("/v1/balances/update", { - customer_id: customer.id || customer.internal_id, - feature_id: featureId, - current_balance: balanceInt, - granted_balance: grantedBalanceInput ?? undefined, - customer_entitlement_id: cusEnt.id, - entity_id: entityId ?? undefined, - next_reset_at: updateFields.next_reset_at ?? undefined, - }); - toast.success("Balance updated successfully"); - await refetch(); - handleClose(); - } catch (error) { - toast.error(getBackendErr(error, "Failed to update entitlement")); - } - setUpdateLoading(false); - }; - - const handleAddToBalance = async () => { - const valueToAdd = parseFloat(addValue); - - setUpdateLoading(true); - try { - await axiosInstance.post("/v1/balances/update", { - customer_id: customer.id || customer.internal_id, - feature_id: featureId, - add_to_balance: valueToAdd, - customer_entitlement_id: selectedCusEnt?.id, - entity_id: entityId ?? undefined, - }); - toast.success("Balance added successfully"); - await refetch(); - handleClose(); - } catch (error) { - toast.error(getBackendErr(error, "Failed to add balance")); - } - setUpdateLoading(false); - }; + const { featureId, originalEntitlements, selectedCusEntId } = + useCustomerBalanceSheetStore(); if (!featureId || !originalEntitlements.length) { return ( @@ -189,12 +56,9 @@ export function BalanceEditSheet() { ); } - const firstEnt = originalEntitlements[0]; - const feature = firstEnt.entitlement.feature; - - const isUnlimited = selectedCusEnt - ? isUnlimitedCusEnt(selectedCusEnt) - : false; + const selectedCusEnt = originalEntitlements.find( + (ent) => ent.id === selectedCusEntId, + ); if (!selectedCusEnt) { return ( @@ -204,18 +68,29 @@ export function BalanceEditSheet() { ); } - const fields = updateFields; - if (!fields) return null; + const isUnlimited = isUnlimitedCusEnt(selectedCusEnt); + const feature = selectedCusEnt.entitlement.feature; - const cusProduct = getCusProduct(selectedCusEnt); + const cusProduct = customer?.customer_products.find( + (cp: FullCusProduct) => cp.id === selectedCusEnt.customer_product_id, + ); const cusPrice = cusProduct?.customer_prices.find( (cp: FullCustomerPrice) => cp.price.entitlement_id === selectedCusEnt.entitlement.id, ); - const showOutOfPopover = - (initialFields.grantedAndPurchasedBalance ?? 0) > 0 || - (initialFields.balance ?? 0) > 0; + const hasOneOffPrepaidPrice = cusPrice + ? isOneOffPrice(cusPrice.price) && isPrepaidPrice(cusPrice.price) + : false; + const hasExistingAutoTopUp = customer?.auto_topups?.some( + (c: AutoTopup) => c.feature_id === featureId, + ); + const isEligibleForAutoTopUp = + hasOneOffPrepaidPrice || !!hasExistingAutoTopUp; + + const existingAutoTopUp = + customer?.auto_topups?.find((c: AutoTopup) => c.feature_id === featureId) ?? + null; return (
@@ -229,184 +104,531 @@ export function BalanceEditSheet() { breadcrumbs={undefined} /> -
- -
- {(() => { - // For loose entitlements, check selectedCusEnt.internal_entity_id - // For product entitlements, check cusProduct.entity_id - const entity = customer?.entities?.find((e: Entity) => { - if (selectedCusEnt.internal_entity_id) { - return e.internal_id === selectedCusEnt.internal_entity_id; - } - return ( - e.internal_id === cusProduct?.internal_entity_id || - e.id === cusProduct?.entity_id - ); - }); - return entity ? ( - - ) : null; - })()} -
- -
- - - {selectedCusEnt.entitlement.interval === "lifetime" - ? "Lifetime" - : selectedCusEnt.entitlement.interval} - - } - /> - {isUnlimited && ( - - Unlimited - - } - /> - )} - - {selectedCusEnt.expires_at && ( - - )} -
+ {isUnlimited ? ( + + ) : ( + + )} +
+ ); +} + +/* ─── Unlimited Info (no form needed) ─── */ + +function UnlimitedBalanceInfo({ + customer, + selectedCusEnt, + cusProduct, +}: { + customer: any; + selectedCusEnt: FullCustomerEntitlement; + cusProduct: FullCusProduct | undefined; +}) { + return ( +
+ + + +
+ ); +} + +/* ─── Inner Form ─── */ + +function BalanceEditForm({ + selectedCusEnt, + entityId, + customer, + cusProduct, + cusPrice, + featureId, + existingAutoTopUp, + isEligibleForAutoTopUp, +}: { + selectedCusEnt: FullCustomerEntitlement; + entityId: string | null; + customer: any; + cusProduct: FullCusProduct | undefined; + cusPrice: FullCustomerPrice | undefined; + featureId: string; + existingAutoTopUp: AutoTopup | null; + isEligibleForAutoTopUp: boolean; +}) { + const form = useBalanceEditForm({ + selectedCusEnt, + entityId, + existingAutoTopUp, + }); + + return ( +
+ + + + + + + + + {isEligibleForAutoTopUp && ( + + + )} + + +
+ ); +} + +/* ─── Entitlement Info Rows ─── */ + +function EntitlementInfoRows({ + customer, + selectedCusEnt, + cusProduct, + isUnlimited, +}: { + customer: any; + selectedCusEnt: FullCustomerEntitlement; + cusProduct: FullCusProduct | undefined; + isUnlimited: boolean; +}) { + const entity = customer?.entities?.find((e: Entity) => { + if (selectedCusEnt.internal_entity_id) { + return e.internal_id === selectedCusEnt.internal_entity_id; + } + return ( + e.internal_id === cusProduct?.internal_entity_id || + e.id === cusProduct?.entity_id + ); + }); + + return ( +
+ {entity && } + + + {selectedCusEnt.entitlement.interval === "lifetime" + ? "Lifetime" + : selectedCusEnt.entitlement.interval} + + } + /> + {isUnlimited && ( + + Unlimited + + } + /> + )} + {selectedCusEnt.expires_at && ( + + )} +
+ ); +} + +/* ─── Balance Fields Section ─── */ + +function BalanceFields({ + form, + selectedCusEnt, + cusPrice, +}: { + form: BalanceEditFormInstance; + selectedCusEnt: FullCustomerEntitlement; + cusPrice: FullCustomerPrice | undefined; +}) { + const mode = useStore(form.store, (s) => s.values.mode); + const feature = selectedCusEnt.entitlement.feature; + + const showOutOfPopover = useStore(form.store, (s) => { + const gpb = s.values.grantedAndPurchasedBalance ?? 0; + const bal = s.values.balance ?? 0; + return gpb > 0 || bal > 0; + }); + + return ( +
+ + {(field) => ( + field.handleChange(v as "set" | "add")} + options={[ + { value: "set", label: "Set Balance" }, + { value: "add", label: "Add to Balance" }, + ]} + /> + )} + + + {mode === "set" ? ( + + ) : ( + + )} +
+ ); +} - {!isUnlimited && ( - -
- setMode(v as "set" | "add")} - options={[ - { value: "set", label: "Set Balance" }, - { value: "add", label: "Add to Balance" }, - ]} - /> - - {mode === "set" ? ( -
-
-
-
- { - const newBalance = e.target.value - ? parseFloat(e.target.value) - : null; - setUpdateFields({ - ...updateFields, - balance: newBalance, - }); - }} - /> -
- {showOutOfPopover && ( - { - setUpdateFields({ - ...updateFields, - grantedAndPurchasedBalance: - newGrantedAndPurchasedBalance, - }); - setGrantedBalanceChanged(true); - }} - /> - )} -
- - {numberWithCommas( - (fields.grantedAndPurchasedBalance ?? 0) - - (fields.balance ?? 0), - )}{" "} - used - -
-
-
-
-
Next Reset
- { - setUpdateFields({ - ...updateFields, - next_reset_at: unixDate, - }); - }} - withTime - use24Hour - /> -
- - -
- ) : ( -
- setAddValue(e.target.value)} - /> - - Current and total granted balance will both be updated. - -
+/* ─── Set Balance Mode ─── */ + +function SetBalanceFields({ + form, + selectedCusEnt, + cusPrice, + feature, + showOutOfPopover, +}: { + form: BalanceEditFormInstance; + selectedCusEnt: FullCustomerEntitlement; + cusPrice: FullCustomerPrice | undefined; + feature: FullCustomerEntitlement["entitlement"]["feature"]; + showOutOfPopover: boolean; +}) { + const balance = useStore(form.store, (s) => s.values.balance); + const gpb = useStore(form.store, (s) => s.values.grantedAndPurchasedBalance); + + return ( +
+
+
+
+ + {(field) => ( + { + const v = e.target.value; + field.handleChange(v ? parseFloat(v) : null); + }} + /> )} -
-
+ {showOutOfPopover && ( + + {(field) => ( + field.handleChange(v)} + /> + )} + + )} +
+ + {numberWithCommas((gpb ?? 0) - (balance ?? 0))} used + +
+
+
+ +
+
Next Reset
+ + {(field) => ( + - {mode === "set" ? "Update Balance" : "Add to Balance"} - - - )} + unixDate={field.state.value} + setUnixDate={(v) => field.handleChange(v)} + withTime + use24Hour + /> + )} +
+ +
); } + +/* ─── Add Balance Mode ─── */ + +function AddBalanceFields({ form }: { form: BalanceEditFormInstance }) { + return ( +
+ + {(field) => ( + { + const v = e.target.value; + field.handleChange(v ? parseFloat(v) : null); + }} + /> + )} + + + Current and total granted balance will both be updated. + +
+ ); +} + +/* ─── Submit Button ─── */ + +function SubmitButton({ + form, + customer, + featureId, + entityId, + selectedCusEnt, + cusPrice, +}: { + form: BalanceEditFormInstance; + customer: any; + featureId: string; + entityId: string | null; + selectedCusEnt: FullCustomerEntitlement; + cusPrice: FullCustomerPrice | undefined; +}) { + const { refetch } = useCusQuery(); + const { closeSheet: closeBalanceSheet } = useCustomerBalanceSheetStore(); + const closeSheet = useSheetStore((s) => s.closeSheet); + const axiosInstance = useAxiosInstance(); + const [loading, setLoading] = useState(false); + + const isDirty = useStore(form.store, (s) => s.isDirty); + + const handleClose = () => { + closeBalanceSheet(); + closeSheet(); + }; + + const handleSave = async () => { + const values = form.state.values; + const promises: Promise[] = []; + + // Validate before firing any requests + if (hasBalanceChanges({ form })) { + if (values.mode === "set") { + const balanceNum = parseFloat(String(values.balance)); + if (Number.isNaN(balanceNum)) { + toast.error("Please enter a valid balance"); + return; + } + if (cusPrice && values.nextResetAt !== selectedCusEnt.next_reset_at) { + toast.error("Not allowed to change reset date for paid features"); + return; + } + } else { + const addNum = parseFloat(String(values.addValue)); + if (Number.isNaN(addNum)) { + toast.error("Please enter a valid amount"); + return; + } + } + } + + setLoading(true); + try { + // Queue balance update + if (hasBalanceChanges({ form })) { + if (values.mode === "set") { + const grantedBalanceInput = computeGrantedBalanceInput({ + newGPB: values.grantedAndPurchasedBalance ?? 0, + defaultGPB: + form.options.defaultValues?.grantedAndPurchasedBalance ?? 0, + defaultBalance: form.options.defaultValues?.balance ?? 0, + prepaidAllowance: form.prepaidAllowance, + }); + + promises.push( + axiosInstance.post("/v1/balances/update", { + customer_id: customer.id || customer.internal_id, + feature_id: featureId, + current_balance: parseFloat(String(values.balance)), + granted_balance: grantedBalanceInput ?? undefined, + customer_entitlement_id: selectedCusEnt.id, + entity_id: entityId ?? undefined, + next_reset_at: values.nextResetAt ?? undefined, + }), + ); + } else { + promises.push( + axiosInstance.post("/v1/balances/update", { + customer_id: customer.id || customer.internal_id, + feature_id: featureId, + add_to_balance: parseFloat(String(values.addValue)), + customer_entitlement_id: selectedCusEnt.id, + entity_id: entityId ?? undefined, + }), + ); + } + } + + // Queue auto top-up update + if (hasAutoTopUpChanges({ form })) { + const autoTopUp = values.autoTopUp; + const newConfig: AutoTopup = { + feature_id: featureId, + enabled: autoTopUp.enabled, + threshold: autoTopUp.threshold ?? 0, + quantity: autoTopUp.quantity ?? 1, + ...(autoTopUp.enabled && + autoTopUp.maxPurchasesEnabled && { + purchase_limit: { + interval: autoTopUp.interval, + limit: autoTopUp.maxPurchases ?? 1, + }, + }), + }; + + const otherConfigs = (customer.auto_topups ?? []).filter( + (c: AutoTopup) => c.feature_id !== featureId, + ); + + promises.push( + CusService.updateCustomer({ + axios: axiosInstance, + customer_id: customer.id || customer.internal_id, + data: { + billing_controls: { + auto_topups: [...otherConfigs, newConfig], + }, + }, + }), + ); + } + + await Promise.all(promises); + toast.success("Updated successfully"); + handleClose(); + refetch(); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update")); + setLoading(false); + } + }; + + return ( +
+ +
+ ); +} + +/* ─── Dirty Helpers ─── */ + +function hasBalanceChanges({ + form, +}: { + form: BalanceEditFormInstance; +}): boolean { + const meta = form.state.fieldMeta; + + if (form.state.values.mode === "add") { + return meta.addValue?.isDirty ?? false; + } + + return ( + meta.balance?.isDirty || + meta.nextResetAt?.isDirty || + meta.grantedAndPurchasedBalance?.isDirty || + false + ); +} + +function hasAutoTopUpChanges({ + form, +}: { + form: BalanceEditFormInstance; +}): boolean { + const meta = form.state.fieldMeta; + + return ( + meta["autoTopUp.enabled"]?.isDirty || + meta["autoTopUp.threshold"]?.isDirty || + meta["autoTopUp.quantity"]?.isDirty || + meta["autoTopUp.maxPurchasesEnabled"]?.isDirty || + meta["autoTopUp.interval"]?.isDirty || + meta["autoTopUp.maxPurchases"]?.isDirty || + false + ); +} diff --git a/vite/src/views/customers2/components/sheets/balanceEditFormSchema.ts b/vite/src/views/customers2/components/sheets/balanceEditFormSchema.ts new file mode 100644 index 000000000..93fa047d4 --- /dev/null +++ b/vite/src/views/customers2/components/sheets/balanceEditFormSchema.ts @@ -0,0 +1,66 @@ +import { BillingInterval } from "@autumn/shared"; +import { z } from "zod/v4"; + +export const BalanceEditFormSchema = z + .object({ + mode: z.enum(["set", "add"]), + balance: z.number().nullable(), + grantedAndPurchasedBalance: z.number().nullable(), + nextResetAt: z.number().nullable(), + addValue: z.number().nullable(), + autoTopUp: z.object({ + enabled: z.boolean(), + threshold: z.number().min(0).nullable(), + quantity: z.number().min(1).nullable(), + maxPurchasesEnabled: z.boolean(), + interval: z.enum(BillingInterval), + maxPurchases: z.number().min(1).nullable(), + }), + }) + .check((ctx) => { + const { mode, balance, addValue, autoTopUp } = ctx.value; + + if (mode === "set" && balance === null) { + ctx.issues.push({ + code: "custom", + message: "Please enter a valid balance", + path: ["balance"], + }); + } + + if (mode === "add" && (addValue === null || Number.isNaN(addValue))) { + ctx.issues.push({ + code: "custom", + message: "Please enter a valid amount", + path: ["addValue"], + }); + } + + if (autoTopUp.enabled) { + if (autoTopUp.threshold === null || autoTopUp.threshold < 0) { + ctx.issues.push({ + code: "custom", + message: "Threshold must be 0 or above", + path: ["autoTopUp", "threshold"], + }); + } + if (autoTopUp.quantity === null || autoTopUp.quantity < 1) { + ctx.issues.push({ + code: "custom", + message: "Quantity must be 1 or above", + path: ["autoTopUp", "quantity"], + }); + } + if (autoTopUp.maxPurchasesEnabled) { + if (autoTopUp.maxPurchases === null || autoTopUp.maxPurchases < 1) { + ctx.issues.push({ + code: "custom", + message: "Max purchases must be 1 or above", + path: ["autoTopUp", "maxPurchases"], + }); + } + } + } + }); + +export type BalanceEditForm = z.infer; diff --git a/vite/src/views/customers2/components/sheets/useBalanceEditForm.ts b/vite/src/views/customers2/components/sheets/useBalanceEditForm.ts new file mode 100644 index 000000000..055a6ff9c --- /dev/null +++ b/vite/src/views/customers2/components/sheets/useBalanceEditForm.ts @@ -0,0 +1,68 @@ +import { + type AutoTopup, + BillingInterval, + cusEntsToBalance, + cusEntsToGrantedBalance, + cusEntsToPrepaidQuantity, + type FullCustomerEntitlement, + nullish, +} from "@autumn/shared"; +import { useAppForm } from "@/hooks/form/form"; +import { + type BalanceEditForm, + BalanceEditFormSchema, +} from "./balanceEditFormSchema"; + +export function useBalanceEditForm({ + selectedCusEnt, + entityId, + existingAutoTopUp, +}: { + selectedCusEnt: FullCustomerEntitlement; + entityId: string | null; + existingAutoTopUp: AutoTopup | null; +}) { + const prepaidAllowance = cusEntsToPrepaidQuantity({ + cusEnts: [selectedCusEnt], + sumAcrossEntities: nullish(entityId), + }); + + const balance = cusEntsToBalance({ + cusEnts: [selectedCusEnt], + entityId: entityId ?? undefined, + withRollovers: true, + }); + + const grantedBalance = cusEntsToGrantedBalance({ + cusEnts: [selectedCusEnt], + entityId: entityId ?? undefined, + }); + + const grantedAndPurchasedBalance = grantedBalance + prepaidAllowance; + + const form = useAppForm({ + defaultValues: { + mode: "set", + balance: balance ?? null, + grantedAndPurchasedBalance: grantedAndPurchasedBalance ?? null, + nextResetAt: selectedCusEnt.next_reset_at ?? null, + addValue: null, + autoTopUp: { + enabled: existingAutoTopUp?.enabled ?? false, + threshold: existingAutoTopUp?.threshold ?? null, + quantity: existingAutoTopUp?.quantity ?? null, + maxPurchasesEnabled: !!existingAutoTopUp?.purchase_limit, + interval: + existingAutoTopUp?.purchase_limit?.interval ?? BillingInterval.Month, + maxPurchases: existingAutoTopUp?.purchase_limit?.limit ?? null, + }, + } as BalanceEditForm, + validators: { + onChange: BalanceEditFormSchema, + }, + }); + + return Object.assign(form, { prepaidAllowance }); +} + +export type BalanceEditFormInstance = ReturnType;