fix: sandbox level usage alerts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
granted: number;
|
||||
remaining: number;
|
||||
usage: number;
|
||||
before: {
|
||||
balance: {
|
||||
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,
|
||||
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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user