diff --git a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts index 1d17274f8..9d34c37fd 100644 --- a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts +++ b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts @@ -1,5 +1,6 @@ import { type ApiBalanceV1, + AppEnv, type DbUsageAlert, type Feature, type FullCustomer, @@ -206,8 +207,12 @@ export const checkUsageAlerts = async ({ scope: "customer", }); - // 2. Org-level alerts (apply to all customers; evaluated against customer-level balance) - const orgAlerts = ctx.org.config?.usage_alerts ?? []; + // 2. Org-level alerts (apply to all customers; evaluated against customer-level balance). + // Env-scoped: sandbox reads sandbox_usage_alerts, live reads usage_alerts. + const orgAlerts = + ctx.env === AppEnv.Sandbox + ? (ctx.org.config?.sandbox_usage_alerts ?? []) + : (ctx.org.config?.usage_alerts ?? []); if (orgAlerts.length > 0) { await processAlerts({ ctx, diff --git a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildBalanceChanges.ts b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildBalanceChanges.ts index d7e61ad85..4ff7ef4e3 100644 --- a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildBalanceChanges.ts +++ b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildBalanceChanges.ts @@ -1,5 +1,37 @@ import type { ApiBalanceV1 } from "@autumn/shared"; -import type { PreviewBalanceChange } from "./types/index.js"; +import type { PreviewBalance, PreviewBalanceChange } from "./types/index.js"; + +const balanceSubset = (balance: ApiBalanceV1 | undefined): PreviewBalance => ({ + granted: balance?.granted ?? 0, + remaining: balance?.remaining ?? 0, + usage: balance?.usage ?? 0, + unlimited: balance?.unlimited ?? false, + next_reset_at: balance?.next_reset_at ?? null, +}); + +const TRACKED_FIELDS: ReadonlyArray = [ + "granted", + "remaining", + "usage", + "unlimited", + "next_reset_at", +]; + +const diffPreviousAttributes = ({ + before, + after, +}: { + before: PreviewBalance; + after: PreviewBalance; +}): Record => { + const previous: Record = {}; + for (const field of TRACKED_FIELDS) { + if (before[field] !== after[field]) { + previous[field] = before[field]; + } + } + return previous; +}; export const buildBalanceChanges = ({ beforeBalances, @@ -14,31 +46,25 @@ export const buildBalanceChanges = ({ ]); return Array.from(featureIds).flatMap((featureId) => { - const before = beforeBalances[featureId]; const after = afterBalances[featureId]; - const beforeSnapshot = { - granted: before?.granted ?? 0, - remaining: before?.remaining ?? 0, - usage: before?.usage ?? 0, - }; - const afterSnapshot = { - granted: after?.granted ?? 0, - remaining: after?.remaining ?? 0, - usage: after?.usage ?? 0, - }; + // Feature disappeared post-migration → flag_changes / plan_changes own + // that signal; we don't emit a balance_change carrying nothing. + if (!after) return []; - if ( - beforeSnapshot.granted === afterSnapshot.granted && - beforeSnapshot.remaining === afterSnapshot.remaining && - beforeSnapshot.usage === afterSnapshot.usage - ) - return []; + const before = balanceSubset(beforeBalances[featureId]); + const balance = balanceSubset(after); + const previous_attributes = diffPreviousAttributes({ + before, + after: balance, + }); + + if (Object.keys(previous_attributes).length === 0) return []; return [ { feature_id: featureId, - ...afterSnapshot, - before: beforeSnapshot, + balance, + previous_attributes, }, ]; }); diff --git a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts index 9768a36fb..ce5c0d341 100644 --- a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts +++ b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts @@ -1,4 +1,8 @@ -import type { AutumnBillingPlan, FullCusProduct } from "@autumn/shared"; +import type { + AutumnBillingPlan, + FullCusProduct, + FullCustomerEntitlement, +} from "@autumn/shared"; import { getDeleteCustomerProducts, getPatchCustomerProducts, @@ -23,20 +27,88 @@ const customerProductToPlanChange = ({ item_changes: itemChanges, }); +const buildUpdatedPreviousAttributes = ({ + oldCustomerEntitlement, + newCustomerEntitlement, +}: { + oldCustomerEntitlement: FullCustomerEntitlement; + newCustomerEntitlement: FullCustomerEntitlement; +}): Record => { + const previous: Record = {}; + + const oldIncluded = oldCustomerEntitlement.entitlement.allowance ?? null; + const newIncluded = newCustomerEntitlement.entitlement.allowance ?? null; + if (oldIncluded !== newIncluded) previous.included = oldIncluded; + + const oldUnlimited = Boolean(oldCustomerEntitlement.unlimited); + const newUnlimited = Boolean(newCustomerEntitlement.unlimited); + if (oldUnlimited !== newUnlimited) previous.unlimited = oldUnlimited; + + return previous; +}; + +/** + * Pair up patch-level insert/delete customer_entitlements that share a + * `feature_id` and emit a single `"updated"` item_change for each pair. + * Unpaired inserts/deletes stay as their own `"created"` / `"deleted"` + * entries. When multiple cusEnts for the same feature are touched (e.g. + * monthly + lifetime), they pair in arrival order; the dashboard sees N + * `"updated"` entries for that feature. + */ const buildPatchItemChanges = ({ patch, }: { patch: NonNullable[number]; -}): PreviewPlanItemChange[] => [ - ...patch.insertCustomerEntitlements.map((customerEntitlement) => ({ - action: "created" as const, - feature_id: customerEntitlement.entitlement.feature.id, - })), - ...patch.deleteCustomerEntitlements.map((customerEntitlement) => ({ - action: "deleted" as const, - feature_id: customerEntitlement.entitlement.feature.id, - })), -]; +}): PreviewPlanItemChange[] => { + const changes: PreviewPlanItemChange[] = []; + + const insertsByFeature = new Map(); + for (const insert of patch.insertCustomerEntitlements) { + const featureId = insert.entitlement.feature.id; + const existing = insertsByFeature.get(featureId) ?? []; + existing.push(insert); + insertsByFeature.set(featureId, existing); + } + + const remainingDeletes: FullCustomerEntitlement[] = []; + for (const deleted of patch.deleteCustomerEntitlements) { + const featureId = deleted.entitlement.feature.id; + const matchingInserts = insertsByFeature.get(featureId); + const paired = matchingInserts?.shift(); + if (paired) { + changes.push({ + action: "updated", + feature_id: featureId, + previous_attributes: buildUpdatedPreviousAttributes({ + oldCustomerEntitlement: deleted, + newCustomerEntitlement: paired, + }), + }); + continue; + } + remainingDeletes.push(deleted); + } + + for (const inserts of insertsByFeature.values()) { + for (const insert of inserts) { + changes.push({ + action: "created", + feature_id: insert.entitlement.feature.id, + previous_attributes: {}, + }); + } + } + + for (const deleted of remainingDeletes) { + changes.push({ + action: "deleted", + feature_id: deleted.entitlement.feature.id, + previous_attributes: {}, + }); + } + + return changes; +}; export const buildPlanChanges = ({ autumnBillingPlan, diff --git a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewBalanceChange.ts b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewBalanceChange.ts index 8cf3b5929..62639a727 100644 --- a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewBalanceChange.ts +++ b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewBalanceChange.ts @@ -1,17 +1,27 @@ import { z } from "zod/v4"; -const PreviewBalanceSnapshotSchema = z.object({ +/** + * Per-feature balance change in a migration preview. + * + * Shape mirrors the `billing.updated` webhook: `balance` is a small subset + * of `ApiBalanceV1` representing the post-migration state, plus + * `previous_attributes` (sparse — only fields whose value differs from + * pre-migration). The dashboard can render "granted: 100 → 250" by overlaying + * `previous_attributes` on top of the snapshot. + */ +export const PreviewBalanceSchema = z.object({ granted: z.number(), remaining: z.number(), usage: z.number(), + unlimited: z.boolean(), + next_reset_at: z.number().nullable(), }); export const PreviewBalanceChangeSchema = z.object({ feature_id: z.string(), - granted: z.number(), - remaining: z.number(), - usage: z.number(), - before: PreviewBalanceSnapshotSchema, + balance: PreviewBalanceSchema, + previous_attributes: z.record(z.string(), z.unknown()).default({}), }); +export type PreviewBalance = z.infer; export type PreviewBalanceChange = z.infer; diff --git a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts index b097ce902..58947c09b 100644 --- a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts +++ b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts @@ -1,8 +1,9 @@ import { z } from "zod/v4"; export const PreviewPlanItemChangeSchema = z.object({ - action: z.enum(["created", "deleted"]), + action: z.enum(["created", "updated", "deleted"]), feature_id: z.string(), + previous_attributes: z.record(z.string(), z.unknown()).default({}), }); export const PreviewPlanChangeSchema = z.object({ diff --git a/server/tests/integration/balances/track/usage-alerts/usage-alert-org-env.test.ts b/server/tests/integration/balances/track/usage-alerts/usage-alert-org-env.test.ts new file mode 100644 index 000000000..920715f98 --- /dev/null +++ b/server/tests/integration/balances/track/usage-alerts/usage-alert-org-env.test.ts @@ -0,0 +1,251 @@ +/** + * TDD coverage for env-discriminated org usage alerts. + * + * Contract under test: + * New types/fields: + * - OrgConfig.sandbox_usage_alerts: DbUsageAlert[] + * New behaviors: + * - In sandbox env, checkUsageAlerts reads ctx.org.config.sandbox_usage_alerts. + * - In sandbox env, ctx.org.config.usage_alerts (the live field) is IGNORED + * — alerts on it do NOT fire. + * Side effects: + * - A sandbox_usage_alerts entry produces a Svix balances.usage_alert_triggered + * webhook when its threshold is crossed. + * - A usage_alerts entry alone produces NO webhook when the runtime is sandbox. + * + * Pre-impl red: + * - OrgConfig.sandbox_usage_alerts type doesn't exist → TS error. + * - checkUsageAlerts still reads ctx.org.config.usage_alerts unconditionally, + * so test 2 (which expects no webhook from usage_alerts in sandbox) fails. + * + * Post-impl green: both behaviors hold once OrgConfigSchema gains + * sandbox_usage_alerts and checkUsageAlerts switches on ctx.env. + */ + +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import type { DbUsageAlert } from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import defaultCtx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { db } from "@/db/initDrizzle.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; + +type BalancesUsageAlertTriggeredPayload = { + type: string; + data: { + customer_id: string; + feature_id: string; + entity_id?: string; + usage_alert: { + name?: string; + threshold: number; + threshold_type: string; + }; + }; +}; + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: defaultCtx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["balances.usage_alert_triggered"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); + await setOrgAlerts({ sandbox: [], live: [] }); +}); + +afterEach(async () => { + await setOrgAlerts({ sandbox: [], live: [] }); +}); + +async function setOrgAlerts({ + sandbox, + live, +}: { + sandbox: DbUsageAlert[]; + live: DbUsageAlert[]; +}) { + await OrgService.update({ + db, + orgId: defaultCtx.org.id, + updates: { + config: { + ...defaultCtx.org.config, + sandbox_usage_alerts: sandbox, + usage_alerts: live, + }, + }, + }); +} + +// ── Contract assertion 1: sandbox_usage_alerts fires in sandbox env ── + +test(`${chalk.yellowBright("org-alert-env1: sandbox_usage_alerts triggers webhook in sandbox env")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const prod = products.base({ + id: "org-ua-env-sandbox-fires", + items: [messagesItem], + }); + + await setOrgAlerts({ + sandbox: [ + { + feature_id: TestFeature.Messages, + threshold: 500, + threshold_type: "usage", + enabled: true, + name: "sandbox-only-alert", + }, + ], + live: [], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "org-usage-alert-env-sandbox-fires", + setup: [s.customer({ testClock: false }), s.products({ list: [prod] })], + actions: [s.attach({ productId: prod.id })], + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 600, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.usage_alert?.name === "sandbox-only-alert", + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result!.payload.data.usage_alert.threshold).toBe(500); +}); + +// ── Contract assertion 2: usage_alerts (live field) does NOT fire in sandbox ── + +test(`${chalk.yellowBright("org-alert-env2: usage_alerts (live field) is ignored in sandbox env")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const prod = products.base({ + id: "org-ua-env-live-ignored", + items: [messagesItem], + }); + + await setOrgAlerts({ + sandbox: [], + live: [ + { + feature_id: TestFeature.Messages, + threshold: 400, + threshold_type: "usage", + enabled: true, + name: "live-only-alert-should-not-fire", + }, + ], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "org-usage-alert-env-live-ignored", + setup: [s.customer({ testClock: false }), s.products({ list: [prod] })], + actions: [s.attach({ productId: prod.id })], + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 700, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.usage_alert?.name === "live-only-alert-should-not-fire", + timeoutMs: 8000, + }); + + expect(result).toBeNull(); +}); + +// ── Contract assertion 3: only the sandbox entry fires when both fields are set ── + +test(`${chalk.yellowBright("org-alert-env3: with both fields set in sandbox, only sandbox_usage_alerts triggers")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const prod = products.base({ + id: "org-ua-env-both-set", + items: [messagesItem], + }); + + await setOrgAlerts({ + sandbox: [ + { + feature_id: TestFeature.Messages, + threshold: 300, + threshold_type: "usage", + enabled: true, + name: "sandbox-side", + }, + ], + live: [ + { + feature_id: TestFeature.Messages, + threshold: 300, + threshold_type: "usage", + enabled: true, + name: "live-side", + }, + ], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "org-usage-alert-env-both", + setup: [s.customer({ testClock: false }), s.products({ list: [prod] })], + actions: [s.attach({ productId: prod.id })], + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 500, + }); + + const sandboxResult = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.usage_alert?.name === "sandbox-side", + timeoutMs: 15000, + }); + expect(sandboxResult).not.toBeNull(); + + const liveResult = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.usage_alert?.name === "live-side", + timeoutMs: 6000, + }); + expect(liveResult).toBeNull(); +}); diff --git a/server/tests/integration/balances/track/usage-alerts/usage-alert-org-over-100pct.test.ts b/server/tests/integration/balances/track/usage-alerts/usage-alert-org-over-100pct.test.ts index 21dff927f..4cdc3181e 100644 --- a/server/tests/integration/balances/track/usage-alerts/usage-alert-org-over-100pct.test.ts +++ b/server/tests/integration/balances/track/usage-alerts/usage-alert-org-over-100pct.test.ts @@ -68,13 +68,15 @@ afterEach(async () => { }); async function setOrgUsageAlerts(usageAlerts: DbUsageAlert[]) { + // Tests run in AppEnv.Sandbox — checkUsageAlerts reads + // sandbox_usage_alerts, not the live `usage_alerts` field. await OrgService.update({ db, orgId: defaultCtx.org.id, updates: { config: { ...defaultCtx.org.config, - usage_alerts: usageAlerts, + sandbox_usage_alerts: usageAlerts, }, }, }); diff --git a/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts b/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts index 1e19c6132..13a4e3329 100644 --- a/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts +++ b/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts @@ -92,13 +92,15 @@ afterEach(async () => { }); async function setOrgUsageAlerts(usageAlerts: DbUsageAlert[]) { + // Tests run in AppEnv.Sandbox — checkUsageAlerts reads + // sandbox_usage_alerts, not the live `usage_alerts` field. await OrgService.update({ db, orgId: defaultCtx.org.id, updates: { config: { ...defaultCtx.org.config, - usage_alerts: usageAlerts, + sandbox_usage_alerts: usageAlerts, }, }, }); diff --git a/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview-update-items.test.ts b/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview-update-items.test.ts new file mode 100644 index 000000000..998d00f8c --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview-update-items.test.ts @@ -0,0 +1,290 @@ +/** + * TDD coverage for migrateCustomer preview shape on update_items migrations. + * + * Contract under test: + * New types/fields: + * - balance_changes[i] is a full ApiBalanceV1 snapshot (object, feature_id, + * granted, remaining, usage, breakdown[], rollovers[], next_reset_at...) + * plus a sparse `previous_attributes` carrying the OLD values of fields + * that changed. + * - The legacy `before: { granted, remaining, usage }` shape is gone. + * New behaviors: + * - For a no-usage `update_items` bump (included 100 → 250), preview emits + * a single balance_change with new granted/remaining = 250 and + * previous_attributes.granted = previous_attributes.remaining = 100. + * - Fields that stayed the same (e.g. usage = 0 before and after) are + * omitted from previous_attributes. + * - When `update_items` lowers included but tracked usage is preserved, + * the balance_change reflects new remaining, with previous_attributes + * containing the old granted (and old remaining if it differs). + * - Migration that doesn't touch a given feature does NOT emit a + * balance_change for it. + */ + +import { expect, test } from "bun:test"; +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"; + +type MigrationClient = Awaited>["autumnV2_2"]; + +const timeout = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const deepParse = (value: unknown): unknown => { + if (typeof value === "string") { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + return deepParse(JSON.parse(value)); + } catch { + return value; + } + } + return value; + } + if (Array.isArray(value)) return value.map(deepParse); + if (value && typeof value === "object") { + const result: Record = {}; + for (const [k, v] of Object.entries(value)) result[k] = deepParse(v); + return result; + } + return value; +}; + +const parseResponse = (response: unknown): Record => { + const parsed = deepParse(response); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) + return parsed as Record; + throw new Error(`Invalid migration event response: ${String(response)}`); +}; + +const waitForPreview = async ({ + autumnV2_2, + migrationId, + migrationRunId, + timeoutMs = 45_000, +}: { + autumnV2_2: MigrationClient; + migrationId: string; + migrationRunId: string; + timeoutMs?: number; +}): Promise> => { + const start = Date.now(); + let lastError: unknown; + while (Date.now() - start < timeoutMs) { + try { + const events = await autumnV2_2.migrationsV2.listItemEvents({ + migrationId, + migrationRunId, + }); + const event = events.list[0]; + if (!event) throw new Error("No migration item event found"); + const response = parseResponse(event.response); + const preview = response.preview; + if (!preview) throw new Error("Migration item event missing preview"); + return preview as Record; + } catch (error) { + lastError = error; + await timeout(1_000); + } + } + throw new Error( + `Timed out waiting for migration preview: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +}; + +const runPreviewMigration = async ({ + autumnV2_2, + migrationId, + filter, + operations, +}: { + autumnV2_2: MigrationClient; + migrationId: string; + filter: Parameters< + MigrationClient["migrationsV2"]["deleteAndCreate"] + >[0]["filter"]; + operations: Parameters< + MigrationClient["migrationsV2"]["deleteAndCreate"] + >[0]["operations"]; +}) => { + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: migrationId, + filter, + operations, + }); + const runResponse = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: true, + }); + return waitForPreview({ + autumnV2_2, + migrationId: migration.id, + migrationRunId: runResponse.run_id, + }); +}; + +test(`${chalk.yellowBright("migrations preview: update_items emits ApiBalanceV1 snapshot + previous_attributes for the touched feature")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-update-items-${suffix}`; + const freePlan = products.base({ + id: `migration-preview-update-items-plan-${suffix}`, + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyCredits({ includedUsage: 50 }), + ], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [freePlan] })], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + const preview = await runPreviewMigration({ + autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: freePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: freePlan.id }, + customize: { + update_items: [ + { filter: { feature_id: TestFeature.Messages }, included: 250 }, + ], + }, + }, + ], + }, + }); + + const balanceChanges = preview.balance_changes as Array< + Record + >; + + // Untouched Credits feature → no entry. + expect( + balanceChanges.some((change) => change.feature_id === TestFeature.Credits), + ).toBe(false); + + const messagesChange = balanceChanges.find( + (change) => change.feature_id === TestFeature.Messages, + ); + expect(messagesChange, "expected a balance change for messages").toBeDefined(); + + const balance = messagesChange?.balance as Record; + expect(balance).toBeDefined(); + expect(balance).toMatchObject({ + granted: 250, + remaining: 250, + usage: 0, + }); + expect(balance).toHaveProperty("unlimited"); + expect(balance).toHaveProperty("next_reset_at"); + + // previous_attributes lives at the balance-change level, NOT inside balance. + expect(balance).not.toHaveProperty("previous_attributes"); + const previous = messagesChange?.previous_attributes as Record< + string, + unknown + >; + expect(previous).toBeDefined(); + expect(previous.granted).toBe(100); + expect(previous.remaining).toBe(100); + + // usage was 0 before and after — must NOT appear in previous_attributes. + expect(previous).not.toHaveProperty("usage"); + + // Top-level shape: just feature_id + balance + previous_attributes. No + // legacy before/granted at the top level. + expect(messagesChange).not.toHaveProperty("granted"); + expect(messagesChange).not.toHaveProperty("before"); + + // update_items collapses to a single "updated" item_change with the old + // included value in previous_attributes. + const planChanges = preview.plan_changes as Array>; + const patch = planChanges.find( + (change) => change.action === "updated" && change.plan_id === freePlan.id, + ); + expect(patch).toBeDefined(); + const itemChanges = patch?.item_changes as Array>; + const messagesItem = itemChanges.find( + (item) => item.feature_id === TestFeature.Messages, + ); + expect(messagesItem).toEqual( + expect.objectContaining({ + action: "updated", + feature_id: TestFeature.Messages, + previous_attributes: expect.objectContaining({ included: 100 }), + }), + ); +}); + +test(`${chalk.yellowBright("migrations preview: update_items with carried usage surfaces previous granted but not previous usage")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-update-items-usage-${suffix}`; + const freePlan = products.base({ + id: `migration-preview-update-items-usage-plan-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [freePlan] })], + actions: [ + s.billing.attach({ productId: freePlan.id }), + s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }), + ], + }); + + const preview = await runPreviewMigration({ + autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: freePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: freePlan.id }, + customize: { + update_items: [ + { filter: { feature_id: TestFeature.Messages }, included: 300 }, + ], + }, + }, + ], + }, + }); + + const balanceChanges = preview.balance_changes as Array< + Record + >; + const change = balanceChanges.find( + (b) => b.feature_id === TestFeature.Messages, + ); + expect(change).toBeDefined(); + + const balance = change?.balance as Record; + // new: granted=300, remaining=270 (300-30 carried usage), usage=30 + expect(balance).toMatchObject({ + granted: 300, + remaining: 270, + usage: 30, + }); + + const previous = change?.previous_attributes as Record; + // previous: granted=100, remaining=70 (100-30), usage=30 (same) + expect(previous.granted).toBe(100); + expect(previous.remaining).toBe(70); + expect(previous).not.toHaveProperty("usage"); +}); diff --git a/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview.test.ts b/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview.test.ts index 36bda2cf7..b1c18678e 100644 --- a/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview.test.ts +++ b/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview.test.ts @@ -18,26 +18,29 @@ import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; +type PreviewPlanItemChange = { + action: "created" | "updated" | "deleted"; + feature_id: string; + previous_attributes: Record; +}; + type PreviewPlanChange = { action: "created" | "updated" | "deleted"; plan_id: string; entity_id?: string | null; - item_changes: Array<{ - action: "created" | "deleted"; - feature_id: string; - }>; + item_changes: PreviewPlanItemChange[]; }; type PreviewBalanceChange = { feature_id: string; - granted: number; - remaining: number; - usage: number; - before: { + balance: { granted: number; remaining: number; usage: number; + unlimited: boolean; + next_reset_at: number | null; }; + previous_attributes: Record; }; type PreviewFlagChange = { @@ -58,10 +61,39 @@ type MigrationClient = Awaited>["autumnV2_2"]; const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Tinybird's `t.json` storage round-trips nested values as JSON-encoded + * strings at one or more levels. Walk the tree, JSON.parse any string that + * looks like a JSON object/array, and return a fully-parsed structure. + */ +const deepParse = (value: unknown): unknown => { + if (typeof value === "string") { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + return deepParse(JSON.parse(value)); + } catch { + return value; + } + } + return value; + } + if (Array.isArray(value)) return value.map(deepParse); + if (value && typeof value === "object") { + const result: Record = {}; + for (const [k, v] of Object.entries(value)) result[k] = deepParse(v); + return result; + } + return value; +}; + const parseResponse = (response: unknown): Record => { - if (typeof response === "string") return JSON.parse(response); - if (response && typeof response === "object") - return response as Record; + const parsed = deepParse(response); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) + return parsed as Record; throw new Error(`Invalid migration event response: ${String(response)}`); }; @@ -182,8 +214,16 @@ test(`${chalk.yellowBright("migrations preview: boolean item add/remove emits fl action: "updated", plan_id: freePlan.id, item_changes: expect.arrayContaining([ - { action: "deleted", feature_id: TestFeature.AdminRights }, - { action: "created", feature_id: TestFeature.Dashboard }, + { + action: "deleted", + feature_id: TestFeature.AdminRights, + previous_attributes: {}, + }, + { + action: "created", + feature_id: TestFeature.Dashboard, + previous_attributes: {}, + }, ]), }), ]); @@ -231,32 +271,44 @@ test(`${chalk.yellowBright("migrations preview: metered grant replacement emits }); expect(preview.flag_changes).toEqual([]); - expect(preview.balance_changes).toEqual([ - { + expect(preview.balance_changes.length).toBe(1); + expect(preview.balance_changes[0]).toEqual( + expect.objectContaining({ feature_id: TestFeature.Credits, - granted: 300, - remaining: 300, - usage: 0, - before: { + balance: expect.objectContaining({ + granted: 300, + remaining: 300, + usage: 0, + }), + previous_attributes: expect.objectContaining({ granted: 100, remaining: 100, - usage: 0, - }, - }, - ]); + }), + }), + ); + // usage stayed at 0 — must NOT appear in previous_attributes + expect( + preview.balance_changes[0].previous_attributes, + ).not.toHaveProperty("usage"); expect( preview.balance_changes.some( (change) => change.feature_id === TestFeature.Messages, ), ).toBe(false); + // remove + add on the same feature collapses into a single "updated" item_change expect(preview.plan_changes).toEqual([ expect.objectContaining({ action: "updated", plan_id: freePlan.id, - item_changes: expect.arrayContaining([ - { action: "deleted", feature_id: TestFeature.Credits }, - { action: "created", feature_id: TestFeature.Credits }, - ]), + item_changes: [ + expect.objectContaining({ + action: "updated", + feature_id: TestFeature.Credits, + previous_attributes: expect.objectContaining({ + included: 100, + }), + }), + ], }), ]); }); @@ -302,25 +354,27 @@ test(`${chalk.yellowBright("migrations preview: version update emits plan, balan expect.arrayContaining([ expect.objectContaining({ feature_id: TestFeature.Messages, - granted: 200, - remaining: 200, - usage: 0, - before: { + balance: expect.objectContaining({ + granted: 200, + remaining: 200, + usage: 0, + }), + previous_attributes: expect.objectContaining({ granted: 100, remaining: 100, - usage: 0, - }, + }), }), expect.objectContaining({ feature_id: TestFeature.Credits, - granted: 50, - remaining: 50, - usage: 0, - before: { + balance: expect.objectContaining({ + granted: 50, + remaining: 50, + usage: 0, + }), + previous_attributes: expect.objectContaining({ granted: 0, remaining: 0, - usage: 0, - }, + }), }), ]), ); diff --git a/shared/models/orgModels/orgConfig.ts b/shared/models/orgModels/orgConfig.ts index a7e2f151b..8b1ecea3a 100644 --- a/shared/models/orgModels/orgConfig.ts +++ b/shared/models/orgModels/orgConfig.ts @@ -3,6 +3,9 @@ import { DbUsageAlertSchema } from "../cusModels/billingControls/usageAlert.js"; export const OrgConfigSchema = z.object({ usage_alerts: z.array(DbUsageAlertSchema).optional().default([]), + /** Sandbox-env-only usage alerts. `checkUsageAlerts` reads this list when + * `ctx.env === AppEnv.Sandbox` and `usage_alerts` when env is live. */ + sandbox_usage_alerts: z.array(DbUsageAlertSchema).optional().default([]), bill_upgrade_immediately: z.boolean().default(true), convert_to_charge_automatically: z.boolean().default(true),