feat: new migration filters

This commit is contained in:
johnyeo
2026-05-26 16:59:22 +01:00
parent f2044bbbe3
commit 7e668616a3
16 changed files with 564 additions and 11 deletions

View File

@@ -39,5 +39,6 @@
".cursor": true,
".mcp.json": true,
".zed": true
}
},
"typescript.native-preview.tsdk": "/Users/johnyeocx/autumn/main/node_modules/@typescript/native-preview"
}

View File

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

View File

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

View File

@@ -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<ReturnType<typeof initScenario>>["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<ReturnType<typeof initScenario>>["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);
});

View File

@@ -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 <op> ?` inside the planScope
* EXISTS subquery.
* - Plan-rooted: emits `p.version <op> ?` 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]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof StringMatcherSchema>;
/** Mirrors `products.version`. */
version?: z.infer<typeof NumberMatcherSchema>;
price?:
| null
| { $eq?: null; $ne?: null }
@@ -66,6 +69,7 @@ export type PlanFilter = {
export const PlanFilterSchema: z.ZodType<PlanFilter> = z.lazy(() =>
z.object({
plan_id: StringMatcherSchema.optional(),
version: NumberMatcherSchema.optional(),
price: nullableObjectFilter(PlanPriceFilterInner).optional(),
addon: BooleanMatcherSchema.optional(),
paid: BooleanMatcherSchema.optional(),

View File

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

View File

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

View File

@@ -176,6 +176,41 @@ function FilterValueInput({
</div>
);
if (config.valueType === "number") {
const isMulti = rule.operator === "in" || rule.operator === "not_in";
if (isMulti)
return (
<input
className="h-8 text-sm rounded-xl px-3 input-base flex-1 min-w-0 text-foreground placeholder:text-tertiary-foreground"
placeholder="1, 2, 3"
value={rule.values.join(", ")}
onChange={(e) =>
onChange({
...rule,
values: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
);
return (
<input
type="number"
className="h-8 text-sm rounded-xl px-3 input-base flex-1 min-w-0 text-foreground placeholder:text-tertiary-foreground"
placeholder="Number"
value={rule.values[0] ?? ""}
onChange={(e) =>
onChange({
...rule,
values: e.target.value === "" ? [] : [e.target.value],
})
}
/>
);
}
if (suggestions && suggestions.length > 0)
return (
<ValuePicker

View File

@@ -1,8 +1,9 @@
import type { PlanFilter, StringMatcher } from "@autumn/shared";
import type { NumberMatcher, PlanFilter, StringMatcher } from "@autumn/shared";
export type FilterField =
| "customer_id"
| "plan_id"
| "version"
| "paid"
| "recurring"
| "price"
@@ -20,7 +21,11 @@ export type FilterOperator =
| "regex"
| "starts_with"
| "exists"
| "not_exists";
| "not_exists"
| "gt"
| "gte"
| "lt"
| "lte";
export type FilterRule = {
field: FilterField;
@@ -38,6 +43,7 @@ export const FILTER_FIELD_OPTIONS: {
}[] = [
{ value: "customer_id", label: "Customer" },
{ value: "plan_id", label: "Plan" },
{ value: "version", label: "Version" },
{ value: "paid", label: "Paid" },
{ value: "recurring", label: "Recurring" },
{ value: "price", label: "Base Price" },
@@ -51,7 +57,7 @@ export const FILTER_FIELD_OPTIONS: {
type OperatorOption = { value: FilterOperator; label: string };
export type FieldConfig = {
operators: OperatorOption[];
valueType: "string" | "boolean" | "none";
valueType: "string" | "boolean" | "number" | "none";
};
const STRING_OPERATORS: OperatorOption[] = [
@@ -70,6 +76,17 @@ const STRING_MATCH_OPERATORS: OperatorOption[] = [
{ value: "not_in", label: "not in" },
];
const NUMBER_OPERATORS: OperatorOption[] = [
{ value: "is", label: "is" },
{ value: "is_not", label: "is not" },
{ value: "gt", label: ">" },
{ 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<FilterField, FieldConfig> = {
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;