fix: sandbox level usage alerts

This commit is contained in:
johnyeo
2026-05-26 20:07:27 +01:00
parent 2010ba9ffb
commit 3e9a635c54
11 changed files with 796 additions and 80 deletions

View File

@@ -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,

View File

@@ -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<keyof PreviewBalance> = [
"granted",
"remaining",
"usage",
"unlimited",
"next_reset_at",
];
const diffPreviousAttributes = ({
before,
after,
}: {
before: PreviewBalance;
after: PreviewBalance;
}): Record<string, unknown> => {
const previous: Record<string, unknown> = {};
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,
},
];
});

View File

@@ -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<string, unknown> => {
const previous: Record<string, unknown> = {};
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<AutumnBillingPlan["patchCustomerProducts"]>[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<string, FullCustomerEntitlement[]>();
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,

View File

@@ -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<typeof PreviewBalanceSchema>;
export type PreviewBalanceChange = z.infer<typeof PreviewBalanceChangeSchema>;

View File

@@ -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({

View File

@@ -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<BalancesUsageAlertTriggeredPayload>({
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<BalancesUsageAlertTriggeredPayload>({
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<BalancesUsageAlertTriggeredPayload>({
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<BalancesUsageAlertTriggeredPayload>({
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();
});

View File

@@ -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,
},
},
});

View File

@@ -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,
},
},
});

View File

@@ -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<ReturnType<typeof initScenario>>["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<string, unknown> = {};
for (const [k, v] of Object.entries(value)) result[k] = deepParse(v);
return result;
}
return value;
};
const parseResponse = (response: unknown): Record<string, unknown> => {
const parsed = deepParse(response);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
return parsed as Record<string, unknown>;
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<Record<string, unknown>> => {
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<string, unknown>;
} 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<string, unknown>
>;
// 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<string, unknown>;
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<Record<string, unknown>>;
const patch = planChanges.find(
(change) => change.action === "updated" && change.plan_id === freePlan.id,
);
expect(patch).toBeDefined();
const itemChanges = patch?.item_changes as Array<Record<string, unknown>>;
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<string, unknown>
>;
const change = balanceChanges.find(
(b) => b.feature_id === TestFeature.Messages,
);
expect(change).toBeDefined();
const balance = change?.balance as Record<string, unknown>;
// 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<string, unknown>;
// 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");
});

View File

@@ -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<string, unknown>;
};
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;
balance: {
granted: number;
remaining: number;
usage: number;
before: {
granted: number;
remaining: number;
usage: number;
unlimited: boolean;
next_reset_at: number | null;
};
previous_attributes: Record<string, unknown>;
};
type PreviewFlagChange = {
@@ -58,10 +61,39 @@ type MigrationClient = Awaited<ReturnType<typeof initScenario>>["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<string, unknown> = {};
for (const [k, v] of Object.entries(value)) result[k] = deepParse(v);
return result;
}
return value;
};
const parseResponse = (response: unknown): Record<string, unknown> => {
if (typeof response === "string") return JSON.parse(response);
if (response && typeof response === "object")
return response as Record<string, unknown>;
const parsed = deepParse(response);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
return parsed as Record<string, unknown>;
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,
balance: expect.objectContaining({
granted: 300,
remaining: 300,
usage: 0,
before: {
}),
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,
balance: expect.objectContaining({
granted: 200,
remaining: 200,
usage: 0,
before: {
}),
previous_attributes: expect.objectContaining({
granted: 100,
remaining: 100,
usage: 0,
},
}),
}),
expect.objectContaining({
feature_id: TestFeature.Credits,
balance: expect.objectContaining({
granted: 50,
remaining: 50,
usage: 0,
before: {
}),
previous_attributes: expect.objectContaining({
granted: 0,
remaining: 0,
usage: 0,
},
}),
}),
]),
);

View File

@@ -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),