diff --git a/.vscode/settings.json b/.vscode/settings.json index cf6a231c4..defd5ba67 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,6 @@ ".cursor": true, ".mcp.json": true, ".zed": true - } + }, + "typescript.native-preview.tsdk": "/Users/johnyeocx/autumn/main/node_modules/@typescript/native-preview" } diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts index 9b97a6db1..3b5cc883c 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts @@ -39,7 +39,10 @@ const getRewards = async ({ for (const reward of rewardArray) { const corresponding = rewards.find( - (r) => r.id === reward || r.promo_codes.some((c) => c.code === reward), + (r) => + r.id === reward || + r.internal_id === reward || + r.promo_codes.some((c) => c.code === reward), ); if (!corresponding) { diff --git a/server/src/internal/rewards/repos/getRewardsByIdOrCode.ts b/server/src/internal/rewards/repos/getRewardsByIdOrCode.ts index c6090a539..7e5280a5c 100644 --- a/server/src/internal/rewards/repos/getRewardsByIdOrCode.ts +++ b/server/src/internal/rewards/repos/getRewardsByIdOrCode.ts @@ -20,6 +20,7 @@ export const getRewardsByIdOrCode = async ({ eq(rewards.env, env), or( inArray(rewards.id, codes), + inArray(rewards.internal_id, codes), ...codes.map( (code) => sql`EXISTS ( SELECT 1 FROM unnest("promo_codes") AS elem diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/plan-filter/plan-filter-version.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/plan-filter/plan-filter-version.test.ts new file mode 100644 index 000000000..0f3453666 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/plan-filter/plan-filter-version.test.ts @@ -0,0 +1,177 @@ +/** + * TDD coverage for MigrationFilter.customer.plan.version. + * + * Contract under test: + * Filter: + * - PlanFilter.version is a NumberMatcher (bare number, $eq, $gt, + * $gte, $lt, $lte, $in, $nin, $ne). + * Behavior: + * - When `plan.version` matches the customer's product version, the + * customer is included in the migration run. + * - When `plan.version` does NOT match, the customer is excluded + * (no migration_item_runs row created). + * Side effects: + * - migration_item_runs rows reflect the customer set actually + * selected by the filter — empty when no customer matches. + */ + +import { expect, test } from "bun:test"; +import { migrationItemRuns } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { and, eq } from "drizzle-orm"; +import { migrationRunRepo } from "@/internal/migrations/v2/repos/index.js"; +import { waitForMigrationResult } from "../../utils/runUpdatePlanMigration"; + +const waitForRunCompleted = async ({ + ctx, + runId, +}: { + ctx: Awaited>["ctx"]; + runId: string; +}) => + waitForMigrationResult({ + timeoutMs: 60_000, + pollIntervalMs: 1_000, + waitFor: async () => { + const [run] = await migrationRunRepo.list({ ctx, internalId: runId }); + if (!run) throw new Error("Run not found"); + if (run.status !== "succeeded" && run.status !== "failed") + throw new Error(`Run still ${run.status}`); + }, + }); + +const countItemRuns = async ({ + ctx, + migrationInternalId, + migrationRunId, +}: { + ctx: Awaited>["ctx"]; + migrationInternalId: string; + migrationRunId: string; +}) => { + const rows = await ctx.db + .select({ id: migrationItemRuns.migration_item_run_id }) + .from(migrationItemRuns) + .where( + and( + eq(migrationItemRuns.migration_internal_id, migrationInternalId), + eq(migrationItemRuns.migration_run_id, migrationRunId), + eq(migrationItemRuns.dry_run, true), + ), + ); + return rows.length; +}; + +test(`${chalk.yellowBright("migrations plan-filter: version filter restricts customer selection by product version")}`, async () => { + const suffix = Date.now(); + const customerId = `mig-plan-filter-version-${suffix}`; + const plan = products.base({ + id: `mig-plan-filter-version-plan-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [plan] })], + actions: [s.billing.attach({ productId: plan.id })], + }); + + // Bump plan to v2. Customer stays on v1. + await autumnV1.products.update(plan.id, { + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + // ── Assertion 1: version: 2 filter excludes the v1 customer ── + const noMatchMigration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `${customerId}-mig-nomatch`, + filter: { + customer: { plan: { plan_id: plan.id, version: 2 } }, + }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + const noMatchRun = await autumnV2_2.migrationsV2.run({ + id: noMatchMigration.id, + dry_run: true, + }); + await waitForRunCompleted({ ctx, runId: noMatchRun.run_id }); + expect( + await countItemRuns({ + ctx, + migrationInternalId: noMatchMigration.internal_id, + migrationRunId: noMatchRun.run_id, + }), + "customer on v1 must NOT match version: 2 filter", + ).toBe(0); + + // ── Assertion 2: version: 1 filter selects the v1 customer ── + const matchMigration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `${customerId}-mig-match`, + filter: { + customer: { plan: { plan_id: plan.id, version: 1 } }, + }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + const matchRun = await autumnV2_2.migrationsV2.run({ + id: matchMigration.id, + dry_run: true, + }); + await waitForRunCompleted({ ctx, runId: matchRun.run_id }); + expect( + await countItemRuns({ + ctx, + migrationInternalId: matchMigration.internal_id, + migrationRunId: matchRun.run_id, + }), + "customer on v1 must match version: 1 filter", + ).toBe(1); + + // ── Assertion 3: $lt: 2 operator also selects the v1 customer ── + const ltMigration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `${customerId}-mig-lt`, + filter: { + customer: { plan: { plan_id: plan.id, version: { $lt: 2 } } }, + }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + const ltRun = await autumnV2_2.migrationsV2.run({ + id: ltMigration.id, + dry_run: true, + }); + await waitForRunCompleted({ ctx, runId: ltRun.run_id }); + expect( + await countItemRuns({ + ctx, + migrationInternalId: ltMigration.internal_id, + migrationRunId: ltRun.run_id, + }), + "customer on v1 must match version: { $lt: 2 } filter", + ).toBe(1); +}); diff --git a/server/tests/unit/compiler/plan/version.test.ts b/server/tests/unit/compiler/plan/version.test.ts new file mode 100644 index 000000000..90ceefe48 --- /dev/null +++ b/server/tests/unit/compiler/plan/version.test.ts @@ -0,0 +1,190 @@ +/** + * TDD coverage for PlanFilter.version (NumberMatcher). + * + * Contract under test: + * Filter: + * - PlanFilter.version accepts bare number (eq), $eq, $ne, $in, $gt, + * $gte, $lt, $lte. + * Compilation: + * - Customer-rooted: emits `p.version ?` inside the planScope + * EXISTS subquery. + * - Plan-rooted: emits `p.version ?` at the root. + */ + +import { describe, expect, test } from "bun:test"; +import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; +import { compilePlanFilter } from "@autumn/shared/api/migrations/compiler/compilePlanFilter.js"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; + +const ctx = contexts.create({ features: [] }); +const ambient = { orgId: "org_test", env: "live" }; + +const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; +const PLAN_AMBIENT = "cp.status IN (?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; +const PLAN_ROOT_AMBIENT = "p.org_id = ? AND p.env = ?"; + +const normalize = (sql: string) => + sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim(); + +describe("PlanFilter.version — customer-rooted compilation", () => { + test("plan.version bare number eq", () => { + const result = compileFilter({ + filter: { plan: { version: 1 } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND p.version = ? + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + 1, + ]); + }); + + test("plan.version $gte", () => { + const result = compileFilter({ + filter: { plan: { version: { $gte: 2 } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND p.version >= ? + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + 2, + ]); + }); + + test("plan.version $lt", () => { + const result = compileFilter({ + filter: { plan: { version: { $lt: 3 } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND p.version < ? + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + 3, + ]); + }); + + test("plan.version $in", () => { + const result = compileFilter({ + filter: { plan: { version: { $in: [1, 2] } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND p.version IN (?, ?) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + 1, + 2, + ]); + }); + + test("plan.version combined $gte + $lte (range)", () => { + const result = compileFilter({ + filter: { plan: { version: { $gte: 2, $lte: 4 } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND (p.version >= ? AND p.version <= ?) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + 2, + 4, + ]); + }); +}); + +describe("PlanFilter.version — plan-rooted compilation", () => { + test("version bare number eq", () => { + const result = compilePlanFilter({ + filter: { version: 2 }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(`${PLAN_ROOT_AMBIENT} AND p.version = ?`), + ); + expect(result.params).toEqual(["org_test", "live", 2]); + }); + + test("version $gt", () => { + const result = compilePlanFilter({ + filter: { version: { $gt: 1 } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(`${PLAN_ROOT_AMBIENT} AND p.version > ?`), + ); + expect(result.params).toEqual(["org_test", "live", 1]); + }); +}); diff --git a/shared/api/migrations/compiler/filterToIr/fields/parseLeaf.ts b/shared/api/migrations/compiler/filterToIr/fields/parseLeaf.ts index 825383719..73e2ece42 100644 --- a/shared/api/migrations/compiler/filterToIr/fields/parseLeaf.ts +++ b/shared/api/migrations/compiler/filterToIr/fields/parseLeaf.ts @@ -4,7 +4,8 @@ import { translateValue } from "./translateValue.js"; /** * Parse a single field's matcher value into one IR leaf or a small AND of - * leaves. Handles the four supported operators: eq, ne, in, exists. + * leaves. Handles the supported operators: eq, ne, in, nin, exists, gt, + * gte, lt, lte. * * Spelling normalization: * - bare value → eq @@ -12,6 +13,7 @@ import { translateValue } from "./translateValue.js"; * - { $ne: null } → exists (true) * - { $eq: null } → eq null * - { $in: [...] } → in + * - { $gt: n } → gt (and same for $gte / $lt / $lte) * * Multiple operators on one field are combined with AND. */ @@ -37,6 +39,14 @@ export function parseLeaf({ else leaves.push(makeLeaf(field, "ne", ops.$ne, ctx) as IRLeaf); } if ("$in" in ops) leaves.push(makeLeaf(field, "in", ops.$in, ctx) as IRLeaf); + if ("$nin" in ops) + leaves.push(makeLeaf(field, "nin", ops.$nin, ctx) as IRLeaf); + if ("$gt" in ops) leaves.push(makeLeaf(field, "gt", ops.$gt, ctx) as IRLeaf); + if ("$gte" in ops) + leaves.push(makeLeaf(field, "gte", ops.$gte, ctx) as IRLeaf); + if ("$lt" in ops) leaves.push(makeLeaf(field, "lt", ops.$lt, ctx) as IRLeaf); + if ("$lte" in ops) + leaves.push(makeLeaf(field, "lte", ops.$lte, ctx) as IRLeaf); if (leaves.length === 0) throw new Error(`No supported operator found on field "${field}"`); diff --git a/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts b/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts index bd7a85d77..f161a8f00 100644 --- a/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts +++ b/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts @@ -19,6 +19,10 @@ export function parsePlanFilter({ children.push( parseLeaf({ field: "plan_id", rawValue: filter.plan_id, ctx }), ); + if (filter.version !== undefined) + children.push( + parseLeaf({ field: "version", rawValue: filter.version, ctx }), + ); if (filter.price !== undefined) children.push(parsePriceExistence(filter.price)); if (filter.addon !== undefined) diff --git a/shared/api/migrations/compiler/ir/irTypes.ts b/shared/api/migrations/compiler/ir/irTypes.ts index 76c978cd2..fe9816f9a 100644 --- a/shared/api/migrations/compiler/ir/irTypes.ts +++ b/shared/api/migrations/compiler/ir/irTypes.ts @@ -10,7 +10,16 @@ * one form per concept. */ -export type LeafOp = "eq" | "ne" | "in" | "exists"; +export type LeafOp = + | "eq" + | "ne" + | "in" + | "nin" + | "exists" + | "gt" + | "gte" + | "lt" + | "lte"; export type LeafValue = | string diff --git a/shared/api/migrations/compiler/irToSql/irToSql.ts b/shared/api/migrations/compiler/irToSql/irToSql.ts index 44466991f..f47b4f84b 100644 --- a/shared/api/migrations/compiler/irToSql/irToSql.ts +++ b/shared/api/migrations/compiler/irToSql/irToSql.ts @@ -179,17 +179,32 @@ function compileLeaf({ params.push(leaf.value); return `${col} <> ?`; } - if (leaf.op === "in") { + if (leaf.op === "in" || leaf.op === "nin") { if (!Array.isArray(leaf.value)) - throw new Error(`$in expects an array on field "${leaf.field}"`); - if (leaf.value.length === 0) return "FALSE"; + throw new Error(`$${leaf.op} expects an array on field "${leaf.field}"`); + const keyword = leaf.op === "in" ? "IN" : "NOT IN"; + if (leaf.value.length === 0) return leaf.op === "in" ? "FALSE" : "TRUE"; const placeholders = leaf.value .map((v) => { params.push(v); return "?"; }) .join(", "); - return `${col} IN (${placeholders})`; + return `${col} ${keyword} (${placeholders})`; + } + if ( + leaf.op === "gt" || + leaf.op === "gte" || + leaf.op === "lt" || + leaf.op === "lte" + ) { + if (leaf.value === null || Array.isArray(leaf.value)) + throw new Error( + `$${leaf.op} requires a scalar value on field "${leaf.field}"`, + ); + const symbol = { gt: ">", gte: ">=", lt: "<", lte: "<=" }[leaf.op]; + params.push(leaf.value); + return `${col} ${symbol} ?`; } throw new Error(`Unsupported op: ${(leaf as IRLeaf).op}`); } diff --git a/shared/api/migrations/compiler/registry/customerRegistry.ts b/shared/api/migrations/compiler/registry/customerRegistry.ts index 6cea929f3..f78f94e97 100644 --- a/shared/api/migrations/compiler/registry/customerRegistry.ts +++ b/shared/api/migrations/compiler/registry/customerRegistry.ts @@ -86,6 +86,7 @@ const planScope: NavScope = { ], fields: { plan_id: { kind: "leaf", sql: "p.id" }, + version: { kind: "leaf", sql: "p.version" }, addon: { kind: "leaf", sql: "p.is_add_on" }, custom: { kind: "leaf", sql: "cp.is_custom" }, // Base price existence: a leaf whose SQL is a scalar subquery that diff --git a/shared/api/migrations/compiler/registry/planRegistry.ts b/shared/api/migrations/compiler/registry/planRegistry.ts index b8ac238f7..5e3d6fe3c 100644 --- a/shared/api/migrations/compiler/registry/planRegistry.ts +++ b/shared/api/migrations/compiler/registry/planRegistry.ts @@ -12,6 +12,7 @@ export const planRegistry: RootScope = { ], fields: { plan_id: { kind: "leaf", sql: "p.id" }, + version: { kind: "leaf", sql: "p.version" }, addon: { kind: "leaf", sql: "p.is_add_on" }, }, }; diff --git a/shared/api/migrations/filters/planFilter.ts b/shared/api/migrations/filters/planFilter.ts index ffae27426..73bec80a5 100644 --- a/shared/api/migrations/filters/planFilter.ts +++ b/shared/api/migrations/filters/planFilter.ts @@ -3,6 +3,7 @@ import { arrayFilter } from "./arrayFilter.js"; import { BooleanMatcherSchema, nullableObjectFilter, + NumberMatcherSchema, StringMatcherSchema, } from "./matcher.js"; import { PlanItemFilterSchema } from "./planItemFilter.js"; @@ -40,6 +41,8 @@ const PlanPriceFilterInner = z.object({}); export type PlanFilter = { plan_id?: z.infer; + /** Mirrors `products.version`. */ + version?: z.infer; price?: | null | { $eq?: null; $ne?: null } @@ -66,6 +69,7 @@ export type PlanFilter = { export const PlanFilterSchema: z.ZodType = z.lazy(() => z.object({ plan_id: StringMatcherSchema.optional(), + version: NumberMatcherSchema.optional(), price: nullableObjectFilter(PlanPriceFilterInner).optional(), addon: BooleanMatcherSchema.optional(), paid: BooleanMatcherSchema.optional(), diff --git a/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts b/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts index 5cc21cee3..6c8eca7a1 100644 --- a/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts +++ b/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts @@ -4,6 +4,7 @@ import { isCustomerProductPaid, isCustomerProductPaidRecurring, } from "../../../../utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.js"; +import { numberMatcherMatches } from "../../../migrations/filters/match/numberMatcherMatches.js"; import { stringMatcherMatches } from "../../../migrations/filters/match/index.js"; import type { PlanFilter } from "../../../migrations/filters/planFilter.js"; @@ -42,6 +43,16 @@ export const planFilterMatchesCustomerProduct = ({ return false; } + if (filter.version !== undefined) { + if ( + !numberMatcherMatches({ + matcher: filter.version, + value: cusProduct.product?.version ?? null, + }) + ) + return false; + } + if ( filter.addon !== undefined && isCustomerProductAddOn(cusProduct) !== filter.addon diff --git a/shared/api/products/utils/match/planFilterMatchesProduct.ts b/shared/api/products/utils/match/planFilterMatchesProduct.ts index ba2745147..52ea5fa7a 100644 --- a/shared/api/products/utils/match/planFilterMatchesProduct.ts +++ b/shared/api/products/utils/match/planFilterMatchesProduct.ts @@ -3,6 +3,7 @@ import { isFreeProduct, isOneOffProduct, } from "../../../../utils/productUtils/classifyProduct/classifyProductUtils.js"; +import { numberMatcherMatches } from "../../../migrations/filters/match/numberMatcherMatches.js"; import { stringMatcherMatches } from "../../../migrations/filters/match/index.js"; import type { PlanFilter } from "../../../migrations/filters/planFilter.js"; @@ -34,6 +35,17 @@ export const planFilterMatchesProduct = ({ } } + if (filter.version !== undefined) { + if ( + !numberMatcherMatches({ + matcher: filter.version, + value: product.version ?? null, + }) + ) { + return false; + } + } + const paid = !isFreeProduct({ prices: product.prices }); if (filter.paid !== undefined && paid !== filter.paid) { return false; diff --git a/vite/src/views/migrations/migration/filters/FilterRow.tsx b/vite/src/views/migrations/migration/filters/FilterRow.tsx index 99c9a737a..4445fb6cd 100644 --- a/vite/src/views/migrations/migration/filters/FilterRow.tsx +++ b/vite/src/views/migrations/migration/filters/FilterRow.tsx @@ -176,6 +176,41 @@ function FilterValueInput({ ); + if (config.valueType === "number") { + const isMulti = rule.operator === "in" || rule.operator === "not_in"; + if (isMulti) + return ( + + onChange({ + ...rule, + values: e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }) + } + /> + ); + return ( + + onChange({ + ...rule, + values: e.target.value === "" ? [] : [e.target.value], + }) + } + /> + ); + } + if (suggestions && suggestions.length > 0) return ( " }, + { value: "gte", label: "≥" }, + { value: "lt", label: "<" }, + { value: "lte", label: "≤" }, + { value: "in", label: "in" }, + { value: "not_in", label: "not in" }, +]; + const BOOLEAN_ONLY: FieldConfig = { operators: [{ value: "is", label: "is" }], valueType: "boolean", @@ -86,6 +103,7 @@ const NULLABLE_ONLY: FieldConfig = { export const FIELD_CONFIGS: Record = { customer_id: { operators: STRING_MATCH_OPERATORS, valueType: "string" }, plan_id: { operators: STRING_OPERATORS, valueType: "string" }, + version: { operators: NUMBER_OPERATORS, valueType: "number" }, paid: BOOLEAN_ONLY, recurring: BOOLEAN_ONLY, price: NULLABLE_ONLY, @@ -137,6 +155,61 @@ function stringMatcherToRule( return { field, operator: "is", values: [] }; } +function numberMatcherToRule( + field: FilterField, + matcher: NumberMatcher | undefined, +): FilterRule | null { + if (matcher === undefined) return null; + if (matcher === null) return { field, operator: "is", values: [] }; + if (typeof matcher === "number") + return { field, operator: "is", values: [String(matcher)] }; + if (matcher.$eq !== undefined && matcher.$eq !== null) + return { field, operator: "is", values: [String(matcher.$eq)] }; + if (matcher.$ne !== undefined && matcher.$ne !== null) + return { field, operator: "is_not", values: [String(matcher.$ne)] }; + if (matcher.$in !== undefined) + return { field, operator: "in", values: matcher.$in.map(String) }; + if (matcher.$nin !== undefined) + return { field, operator: "not_in", values: matcher.$nin.map(String) }; + if (matcher.$gt !== undefined) + return { field, operator: "gt", values: [String(matcher.$gt)] }; + if (matcher.$gte !== undefined) + return { field, operator: "gte", values: [String(matcher.$gte)] }; + if (matcher.$lt !== undefined) + return { field, operator: "lt", values: [String(matcher.$lt)] }; + if (matcher.$lte !== undefined) + return { field, operator: "lte", values: [String(matcher.$lte)] }; + return { field, operator: "is", values: [] }; +} + +function ruleToNumberMatcher(rule: FilterRule): NumberMatcher | undefined { + const nums = rule.values + .map((v) => Number.parseFloat(v)) + .filter((n) => !Number.isNaN(n)); + if (nums.length === 0) return undefined; + const first = nums[0]; + switch (rule.operator) { + case "is": + return nums.length > 1 ? { $in: nums } : first; + case "is_not": + return { $ne: first }; + case "in": + return { $in: nums }; + case "not_in": + return { $nin: nums }; + case "gt": + return { $gt: first }; + case "gte": + return { $gte: first }; + case "lt": + return { $lt: first }; + case "lte": + return { $lte: first }; + default: + return first; + } +} + function ruleToStringMatcher(rule: FilterRule): StringMatcher { if ( rule.operator === "in" || @@ -192,6 +265,9 @@ export function planFilterToGroups(filter: PlanFilter): FilterGroupData[] { const planIdRule = stringMatcherToRule("plan_id", filter.plan_id); if (planIdRule) mainRules.push(planIdRule); + const versionRule = numberMatcherToRule("version", filter.version); + if (versionRule) mainRules.push(versionRule); + if (filter.paid !== undefined) mainRules.push(booleanRule("paid", filter.paid)); @@ -272,6 +348,9 @@ export function groupsToPlanFilter(groups: FilterGroupData[]): PlanFilter { case "plan_id": filter.plan_id = ruleToStringMatcher(rule); break; + case "version": + filter.version = ruleToNumberMatcher(rule); + break; case "paid": filter.paid = rule.values[0] === "true"; break;