# Product Services — Rules Engine v2

Product availability, fees, and limits resolve per-route via a structured rules engine. Each rule carries a `matcher` (structured grammar) and a `value` (the outcome on match). Within a tier, rules evaluate in `priority ASC, id ASC` order — first match wins. How tiers combine differs by domain: activation ANDs across tiers, fees add across tiers, limits merge per-column with a tighten clamp.

This document covers the `product-services` package (`@cfxlabsinc/product-services`). Read this before adding new products, modifying fee/limit logic, or changing activation rules.

---

## Concepts

**Product** — a payment rail identified by a dotted name like `withdraw.us_wire.v1`. Either _routed_ (rows in `product_route`) or _non-routed_ (activation only, e.g. `identity.v1`).

**Route** — a `(vendor, product, bankId?, matcher)` tuple a product is offered on. Deal terms (fees, limits, visibility, matcher) live on an append-only `product_route_version` row; the identity row holds vendor/bank linkage and lifecycle. Aggregate routes (`deposit.*`, `withdraw.*`) hold cross-product limit ceilings. Routes apply across all customers — never customer-pinned.

**Activation rule** — `product_activation_rule` row gating whether a route is available. Three tiers: admin-cross-customer, admin-for-customer, customer-self.

**Fee rule** — `product_fee_rule` row carrying FIXED and/or VARIABLE charges per route, tier-overridable.

**Limit rule** — `product_limit_rule` row carrying per-transaction min/max plus rolling-window (24H, 7D, 30D) count/amount caps. Tier-overridable.

**Block** — `entity_product_block` row that hard-vetoes a product for an entity. Either an admin or customer block is sufficient.

---

## Database Schema

The rules-engine v2 tables follow an **identity / version** split — see [rules/database.md](../rules/database.md) for the cross-cutting pattern.

### `product_route` (identity)

Identity row for a routable surface area. Scoped to `(vendor, productId, matcherHash)` — one logical deal per vendor per product per matcher. `bankId`, `status`, and `priority` are mutable in place; every change to fees, limits, visibility, or matcher inserts a new `product_route_version` row and re-points `currentVersionId`.

| Column             | Type      | Notes                                                                                                                                                                                               |
| ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalId`       | text      | Nano ID prefix `prte`                                                                                                                                                                               |
| `vendor`           | text      | `Vendor` discriminated union — `BankVendor` (`dart`, `metcap`) or `NonBankVendor` (`yellowcard`, `spei`, `self_custody`, `girasol`, `cfx`). Identity-stable                                         |
| `productId`        | integer   | FK → `b2b.product.id`. Identity-stable                                                                                                                                                              |
| `bankId`           | integer   | FK → `b2b.bank.id`, NULL for non-bank vendors. **Must agree with `vendor`** — bank vendors require `bankId === vendor`; non-bank vendors require `bankId IS NULL`. Guard: `VENDOR_BANK_ID_MISMATCH` |
| `matcherHash`      | text      | SHA-256 of the current version's matcher (denormalized; synced by trigger). Used by the dedup index                                                                                                 |
| `currentVersionId` | integer   | Pointer to current `product_route_version` row (nullable; service guarantees non-null when `deletedAt IS NULL`)                                                                                     |
| `status`           | text      | `ACTIVE` (available for routing) or `DISABLED` (parked). Toggleable in place                                                                                                                        |
| `priority`         | integer   | Lower = preferred. Tiebreak: vendor lexicographic                                                                                                                                                   |
| `deletedAt`        | timestamp | Soft-delete; once set, the row is frozen                                                                                                                                                            |

Uniqueness is a partial unique index `(product_id, vendor, matcher_hash) WHERE deleted_at IS NULL`. Services surface the unique-violation as `DUPLICATE_MATCHER`. Soft-deleted rows are exempt; the slot becomes available again for re-use.

Customer-facing base fees and limits live on admin-tier rule rows (`type='ADMIN'`, `customer_id IS NULL`), not on the route. A route with no admin rules and no matching customer rules resolves to an empty fee template — that is not an error.

### `product_route_version` (append-only)

Carries the deal terms for a route. Every change inserts a new row; the identity's `currentVersionId` is repointed in the same transaction. No `externalId` — only the identity row is externally addressable.

| Column                                              | Type      | Notes                                                                          |
| --------------------------------------------------- | --------- | ------------------------------------------------------------------------------ |
| `routeId`                                           | integer   | FK → `product_route.id`                                                        |
| `matcher`                                           | jsonb     | `Matcher` — `RuleGroup` or `"ALWAYS"`                                          |
| `matcherHash`                                       | text      | SHA-256 of the canonical matcher JSON                                          |
| `providerFixedFeeAmount` / `providerVariableFeeBps` | numeric   | CFX-to-provider cost (BigNumber strings). Internal only                        |
| `providerFeeVisible`                                | boolean   | True only for vendors that bill the customer directly                          |
| `providerLimitTransactionMinUsd` / `MaxUsd`         | numeric   | Hard provider-imposed per-transaction bounds                                   |
| `providerLimit{24h,7d,30d}Max{Usd,Count}`           | numeric   | Provider-imposed rolling-window ceilings                                       |
| `label`                                             | text      | Short human description, e.g. "Dart ACH — Program A"                           |
| `createdAt`                                         | timestamp | Effective-from. Effective-to is implicit (next version's `createdAt`, or NULL) |

### `product_activation_rule` / `product_fee_rule` / `product_limit_rule` (identity)

All three identity tables share the same shape:

| Column             | Type      | Notes                                                                                              |
| ------------------ | --------- | -------------------------------------------------------------------------------------------------- |
| `externalId`       | text      | Nano ID prefix `pa` (activation), `pf` (fee), `pl` (limit)                                         |
| `routeId`          | integer   | FK → `product_route.id`                                                                            |
| `customerId`       | integer   | NULL = applies across all customers (admin only); non-null = per-customer                          |
| `type`             | text      | `ADMIN` or `CUSTOMER`. Check constraint: `CUSTOMER` requires non-null `customerId`                 |
| `currentVersionId` | integer   | Pointer to current `_version` row (nullable; service guarantees non-null when `deletedAt IS NULL`) |
| `deletedAt`        | timestamp | Soft-delete; once set, the row is frozen                                                           |

`(routeId, customerId, type)` defines the **scope** — that's intrinsic to the rule's identity. Re-scoping isn't an edit; it's a different rule.

DB-level uniqueness on matcher is preserved by denormalizing a SHA-256 `matcher_hash` (over the JCS-canonical matcher) onto the identity row from the current version, kept in sync by a deferrable composite FK `(current_version_id, matcher_hash) → version (id, matcher_hash)`. The partial unique index lives on `(route_id, customer_id, type, matcher_hash) NULLS NOT DISTINCT WHERE deleted_at IS NULL`. Services compute the hash via `hashMatcher` (`internal.ts`) and catch the unique-violation error as `DUPLICATE_MATCHER`.

### `product_*_rule_version` (append-only)

Three version tables, one per rule family. Common shape:

| Column                 | Type      | Notes                                                             |
| ---------------------- | --------- | ----------------------------------------------------------------- |
| `ruleId`               | integer   | FK → identity table                                               |
| `matcher`              | jsonb     | `Matcher` — `RuleGroup` or the sentinel `"ALWAYS"`                |
| `status`               | text      | `ACTIVE` (in effect) or `DISABLED` (rule version itself disabled) |
| `priority`             | integer   | Lower = evaluated first                                           |
| `label`, `description` | text      | Human-readable                                                    |
| `createdAt`            | timestamp | Effective-from                                                    |

Family-specific value columns:

- **activation_rule_version**: `value text` (`APPROVE` or `DENY`).
- **fee_rule_version**: `fixedFeeAmount`, `variableFeeBps` (BigNumber strings).
- **limit_rule_version**: `transactionMinUsd`, `transactionMaxUsd`, `limit{24h,7d,30d}Max{Usd,Count}` (BigNumber strings; NULL = no cap on that column).

`BEFORE UPDATE OR DELETE` triggers (`b2b.prevent_version_modifications()`) reject any modification — version rows are append-only at the DB layer.

### `product_quote`

Audit table — `ProductQuoteService.quote()` writes one row per transactional quote. Snapshots the input dimensions, the resolved per-receiver fee components, and FKs to the **version** rows that produced the result:

- `routeId` → `product_route.id` (identity is stable)
- `activationRuleVersionId` → `product_activation_rule_version.id`
- `feeRuleVersionId` → `product_fee_rule_version.id`
- `limitRuleVersionId` → `product_limit_rule_version.id`

Because version rows are append-only, the audit chain is frozen end-to-end: every quote is reproducible from its row alone, even if the live rules have been edited many times since. `outcome` is `OK` or `LIMIT_EXCEEDED`; for the latter, `violationType`/`violationWindow`/`violationLimit`/`violationUsage` are populated. External ID prefix `pq`.

`estimate()` does not write to this table — only the transactional `quote()` path does.

### `entity_product_block`

| Column                          | Type    | Notes                              |
| ------------------------------- | ------- | ---------------------------------- |
| `productName`                   | text    | Blocked product                    |
| `identityId` / `organizationId` | integer | Exactly one set (check constraint) |
| `type`                          | text    | `ADMIN` or `CUSTOMER`              |
| `status`                        | text    | `BLOCKED` or `UNBLOCKED`           |
| `note`                          | text    | Reason string                      |

---

## Tier Precedence

Three tiers across all three rule tables:

| `type`     | `customer_id` | Tier                                |
| ---------- | ------------- | ----------------------------------- |
| `ADMIN`    | `NULL`        | Admin baseline across all customers |
| `ADMIN`    | set           | Admin-for-customer adjustment       |
| `CUSTOMER` | set           | Customer's own overlay              |

Within a tier, rules evaluate in `priority ASC, id ASC` order; first match wins. How matched rules combine across tiers differs by domain.

### Activation — single-winner DISTINCT ON tier rank

Per route, the resolver picks **one** activation rule using `DISTINCT ON (route_id)` ordered by tier rank (`CUSTOMER=0` < `ADMIN-for-customer=1` < `ADMIN-global=2`), then `priority ASC`, then `id ASC`. The selected rule's `value` (`APPROVE` or `DENY`) determines route availability: `APPROVE` → route resolves as ACTIVE; `DENY` → INACTIVE.

- A customer-tier rule, when present, wins outright over both admin tiers.
- No rule at all matches the route → status defaults to `INACTIVE` (`reason: NO_MATCHING_RULES`).
- Provenance reports the tier of the selected rule (`SET_BY_CUSTOMER:<id>` / `SET_BY_ADMIN_FOR_CUSTOMER:<id>` / `SET_BY_ADMIN_GLOBALLY:<id>`).

In practice this is functionally equivalent to "admin DENY always wins" because [`ProductActivationRuleService`](../packages/product-services/src/ProductActivationRuleService.ts) rejects any customer-tier write whose merged `value` is non-`DENY` with `CUSTOMER_CANNOT_ACTIVATE`. So a customer rule can only ever DENY the customer themselves; if admin is DENY and the customer wrote nothing, admin's DENY survives. If admin is DENY and the customer also wrote DENY, the customer rule wins — same outcome. The customer cannot "re-allow" past an admin DENY because they can't write `APPROVE`.

### Fees — additive markup

```
customer-facing fee = admin_markup + customer_markup
CFX revenue         = admin_markup
customer revenue    = customer_markup
provider cost       = product_route.provider_*  (internal only)
```

Pick first matching admin rule (admin-for-customer ?? admin-cross-customer) → admin contribution. Pick first matching customer rule → customer contribution. Sum. Missing parts default to 0. Customer rules can only **add** markup — `ProductFeeRuleService.create/replace` rejects negative `fixedFeeAmount` or `variableFeeBps` with `INVALID_FEE_RULE`.

Drift is a non-issue under addition: admin raising their markup propagates through, customer's increment survives unchanged. No clamp.

### Limits — per-column merge + tighten clamp

Limit columns (`transactionMinUsd`, `transactionMaxUsd`, `limit24hMaxUsd`, …) resolve independently:

```
baseline.col   = COALESCE(adminCustomer.col, adminCrossCustomer.col)
effective.col  = max columns:  LEAST(COALESCE(customer.col, baseline.col), baseline.col)
                 min columns:  GREATEST(COALESCE(customer.col, baseline.col), baseline.col)
```

Customer NULL falls through to admin; customer values are clipped against the current admin baseline; admin-for-customer wins per-column over admin-cross-customer. Final clip against `product_route.providerLimit_*` ceilings happens last — no tier can loosen past the provider limit; when a clip occurs, provenance becomes `PROVIDER_LIMIT`.

Customer rules are validated at write time: each column must tighten the matching admin baseline (max columns ≤ baseline, min columns ≥ baseline). Violations return `INVALID_LIMIT_RULE` naming the offending column.

---

## Matcher Grammar

```ts
type Matcher = RuleGroup | "ALWAYS";

type RuleGroup = {
  combinator: "all" | "any" | "none";
  conditions: (RuleCondition | RuleGroup)[];
};

type RuleCondition = { field; operator: "is" | "is_not"; value: string | number | boolean } | { field; operator: "is_one_of" | "is_not_one_of" | "contains_any" | "contains_none"; value: string[] } | { field; operator: "is_set" | "is_not_set" } | { field; operator: "gt" | "gte" | "lt" | "lte"; value: string | number } | { field; operator: "between"; value: [string | number, string | number] } | { field; operator: "starts_with" | "ends_with" | "contains_substring"; value: string };
```

`"ALWAYS"` matches everything. The 16 operators are typed per-shape so callers can't construct a nonsensical `between` with one bound or `is_set` with a value. Set-membership operators take `string[]` — callers stringify booleans/numbers (`["true","false"]`, `["100","200"]`) so jsonb containment compares cleanly.

`field` is `MatcherField` (a `keyof MatcherCriteria`) — see `product_activation_rule.ts` for the union. The fields any product is allowed to match against come from `PRODUCT_FIELD_KEYS` and `FieldsFor<P>` in `internal.ts` (compile-time exhaustiveness check).

**Evaluation:** `ruleMatch(matcher, criteria)` in TypeScript (re-exported from `@cfxlabsinc/db/drizzle`); `b2b.rule_match()` in PL/pgSQL (installed by the rule-engine migration). The two implementations mirror each other.

---

## Service Layer

Two shapes per rule family — admin and customer service. They are **not** symmetric: each side has category-specific write guards. All take `customerId` on consumer methods; admin methods do not.

### `ProductRouteAdminService`

Unified CRUD over `product_route` and its append-only `product_route_version` table. `create({ vendor, productName, bankId?, ...versionFields })` / `update({ id, data })` / `delete({ id })` / `get` / `search({ ids?, vendors?, productNames?, bankIds? })` / `history({ id })`. Identity-field changes (`bankId`, `status`, `priority`) update the route row in place; version-field changes (fees, limits, visibility, matcher, label) insert a new version row and re-point `currentVersionId` in one transaction. `delete()` sets `deletedAt` (soft-delete). `history()` returns every version row for the route, newest first.

- `create()` errors: `VENDOR_BANK_ID_MISMATCH`, `DUPLICATE_MATCHER`
- `update()` errors: `ROUTE_NOT_FOUND`, `VENDOR_BANK_ID_MISMATCH`
- `delete()` errors: `ROUTE_NOT_FOUND`

Routes do NOT seed baseline rules — callers create the activation/fee/limit rules separately via the rule admin services.

### `ProductRouteService`

Read-only route resolution. `search({ productName, customerId, ...criteria })` enumerates every candidate route for the product whose matcher matches the supplied criteria, joining the winning activation/fee/limit rule per route via tier-precedence DISTINCT-ON CTEs, returning `ResolvedRoute[]`. Speed, currencies, and other transaction dimensions flow as matcher criteria — they are not separate search params. `get(externalId)` fetches a single row.

`MatcherCriteria` is `Omit<DbMatcherCriteria, "bankId">` — `bankId` is injected internally from the bank table join, not supplied by callers.

The resolver returns _every_ route (ACTIVE and INACTIVE) tagged with `status` and a typed `reason` so admin dashboards can show which rule drove the verdict. `ProductQuoteService` selects the first ACTIVE route by `priority ASC` (after applying `bankId`/`speed` hints if provided).

### Activation rules

- **`ProductActivationRuleAdminService`** — admin CRUD. No write guards.
- **`ProductActivationRuleService`** — customer CRUD. Write guard: `create()`/`update()` reject `value !== "DENY"` with `CUSTOMER_CANNOT_ACTIVATE`. Customer-tier rules may only DENY (opt themselves out); re-enabling is admin-only.

Both: `create()` / `update()` / `delete()` / `search()` / `get()`. `update()` accepts every mutable field including `matcher` — matcher edits insert a new version row and re-point `currentVersionId` in one transaction. There is no separate `replace()` method (it folded into `update()` once matcher moved to the version row).

### Fee rules

- **`ProductFeeRuleAdminService`** — admin CRUD. No sign guard; admin can set any value.
- **`ProductFeeRuleService`** — customer CRUD. Write guard: `create()`/`update()` reject negative `fixedFeeAmount`/`variableFeeBps` with `INVALID_FEE_RULE`.

Both: `create()` / `update()` / `delete()` / `search()` / `get()`. Same identity/version pattern as activation rules.

### Limit rules

- **`ProductLimitRuleAdminService`** — admin CRUD. No tightening guard.
- **`ProductLimitRuleService`** — customer CRUD. Write guard: per-column tightening (`max` columns ≤ admin baseline, `min` columns ≥ admin baseline), else `INVALID_LIMIT_RULE` naming the offending column.

Both: `create()` / `update()` / `delete()` / `search()` / `get()`. Same identity/version pattern.

### Duplicate detection

The matcher itself moved to the version row, but each identity table denormalizes a `matcher_hash` column from its current version, kept in sync by a deferrable composite FK `(current_version_id, matcher_hash) → version (id, matcher_hash)`. A partial unique index `(route_id, customer_id, type, matcher_hash) NULLS NOT DISTINCT WHERE deleted_at IS NULL` on the identity row enforces the no-duplicates invariant inside the database.

Each rule admin/consumer service computes the canonical hash via `hashMatcher()` (SHA-256 over the JCS-canonicalized matcher) on `create()` / `update()`, writes it into the identity row, and catches the unique-violation error to surface it as `DUPLICATE_MATCHER`. Hashing is deterministic, so semantically-equivalent matchers (different field order, etc.) collide on the index regardless of write order.

See `packages/db/src/drizzle/schema/product_activation_rule.ts` for the schema annotation and `packages/product-services/src/internal.ts` `hashMatcher()` for the canonicalization.

### Block services

Block resolution runs **before** route resolution and short-circuits with `PRODUCT_BLOCKED`. Either ADMIN or CUSTOMER block is sufficient.

- **`ProductBlockAdminService`** — admin block CRUD (`type='ADMIN'`). `create()` / `update()` / `search()` / `isBlocked()`.
- **`ProductBlockService`** — customer block CRUD (`type='CUSTOMER'`). Same surface; `isBlocked({ productName, entityId, customerId })` is the call invoked by `ProductQuoteService`.

### `ProductQuoteService`

Two methods — they share resolution, but only `quote()` enforces limits and writes an audit row.

```ts
estimate(input): Promise<Result<QuoteOutput, PreRouteError>>
quote(input & { getUsage, getAggregateUsage }): Promise<Result<QuoteOutput & { quoteId }, PreRouteError | LimitExceededError>>
```

- **`estimate()`** — stateless preview. Resolves route + fee template + amount math. No limit enforcement, no audit row. Use for UI previews and any caller with no intent to trade.
- **`quote()`** — same math, plus per-product and aggregate-domain limit enforcement, plus a row written to `product_quote`. The returned `quoteId` (external id) should be persisted on downstream consumer rows (deposit/withdrawal/swap quote tables).

`getUsage` and `getAggregateUsage` are TS-required on `quote()` to surface the limit-enforcement decision in code review. Pass `null` to opt out explicitly (documented internal admin flows). Audit scope: a row is written iff math ran with intent to trade — i.e. on `OK` or `LIMIT_EXCEEDED` (route resolved). Pre-route failures (`PRODUCT_INACTIVE`, `NO_ELIGIBLE_ROUTE`, `ENTITY_*`, `PRODUCT_BLOCKED`) do not write.

---

## The `quote()` Flow

```ts
const result = await productQuoteService.quote({
  productName: "withdraw.us_wire.v1",
  entityId,
  customerId,
  sourceCurrency: "MOVEUSD",
  targetCurrency: "USD",
  amount: { source: new BigNumber(500) },
  getUsage, // pass null to opt out
  getAggregateUsage, // pass null to opt out
});
```

### Direction

- `{ source }` — caller knows source; fees deduct from it.
- `{ target }` — caller knows the target the recipient should receive; source is back-solved as `target + totalFees`. Always rounded up so the delivered target is never short — the customer pays the rounding difference.
- `null` — amount unknown (e.g. cash deposits where barcode creation decides). Fee template still resolves; `quote` is `null`; per-transaction limits skipped (rolling-window limits still enforced).

### Steps

1. **Entity check.** Load entity, validate `TRANSACT` capability. Derives `entityType`, `countryCode`, `hasIdentityDocument`. Errors: `ENTITY_NOT_FOUND`, `ENTITY_CANNOT_TRANSACT`.
2. **Block check.** `ProductBlockService.isBlocked()` — active block returns `PRODUCT_BLOCKED` immediately. Block always wins.
3. **Route resolution.** `ProductRouteService.search()` returns every route for `productName` whose version matcher matches the supplied criteria with the winning activation/fee/limit rule per route. First `ACTIVE` route by `priority ASC` is selected. For non-routed products (zero `product_route` rows), falls back to `ProductConfigService.isActive()` — error `PRODUCT_INACTIVE` if not active. If routes exist but none ACTIVE: `NO_ELIGIBLE_ROUTE`.
4. **Fee resolution.** Effective fee = admin contribution + customer contribution (see [Fees](#fees--additive-markup)). For non-routed products, `ProductFeeConfigService.getFees()` is used (legacy product-scoped resolver — receiver-overlay model does not apply to v2). Zero-value entries are filtered out of the template.
5. **Amount math.** Source flow: fixed fees first, variable on remainder. Target flow: back-solve, round up. Null amount: skip.
6. **Limit checks** (only when `getUsage` is provided). Order: TRANSACTION min/max → 24H/7D/30D rolling windows → aggregate domain (`getAggregateUsage`, for `deposit.*`/`withdraw.*` products with an aggregate route seeded). On any violation: `LIMIT_EXCEEDED` with `{ window, type, limit, usage }`. The aggregate check is skipped if `getAggregateUsage` is null or the aggregate route doesn't exist.
7. **Return.**

```ts
{
  entity: Entity & { customerId: string },
  route: ResolvedRoute,             // full route + winning rules
  fees: ProductFee[],               // effective fee template
  quote: {                          // null when amount was null
    sourceAmount: BigNumber,
    targetAmountAfterFees: BigNumber,
    fees: Fees,
    totalFees: BigNumber,
  } | null,
  quoteId: string,                  // only on quote(), not estimate()
}
```

### Optimization

If the resolved limit set has no rolling-window rules (only TRANSACTION min/max or none), `getUsage` is never called.

---

## TypeScript Types

Defined in `internal.ts` and re-exported through the package index:

```ts
type ProductFee = { receiver: "PROVIDER" | "CFX" | "CUSTOMER" } & ({ type: "FIXED"; feeAmount: BigNumber } | { type: "VARIABLE"; feeBps: BigNumber });

type ProductLimit = { limit: BigNumber } & ({ type: "MAX_COUNT" | "MAX_USD"; window: "24H" | "7D" | "30D" } | { type: "MIN_USD" | "MAX_USD"; window: "TRANSACTION" });

type Fees = { receiver; type; amount: BigNumber }[]; // itemized concrete amounts
```

`ResolvedRoute` (from `ProductRouteService.ts`) carries the route columns, the provider-cost fields, and the winning `activation` / `fee` / `limit` rule snapshots — see the file for the full shape.

`PRODUCT_FIELD_KEYS` and `FieldsFor<P>` (in `internal.ts`) constrain matcher fields per product. Adding a `ProductName` without an entry in `PRODUCT_FIELD_KEYS` fails to compile.

---

## Adding a New Product

See [PRODUCT.md](./PRODUCT.md) for the full cascade. In brief:

1. Add the name to `packages/db/src/drizzle/schema/product_config.ts` (`PRODUCT_NAMES` enum).
2. Add a `PRODUCT_FIELD_KEYS` entry in `packages/product-services/src/internal.ts` listing the matcher fields the product can target.
3. Add it to `packages/interface-base/src/models/product.ts`.
4. Seed: insert a `product_route` row + initial `product_route_version` row (carrying deal terms and the matcher), then admin-tier `product_activation_rule` / `product_fee_rule` / `product_limit_rule` rows (each followed by its initial `*_version` row + a `current_version_id` flip). Use the rule admin services rather than raw SQL when possible — they preserve the version-table invariants.
5. In your service, call `productQuoteService.quote()` (or `estimate()` for previews). Pass `getUsage` / `getAggregateUsage` callbacks computing rolling usage for the new product.

---

## Composing multiple products

Some flows quote more than one product (e.g. cash deposit + swap when the target currency isn't MOVEUSD). The caller composes — call `quote()`/`estimate()` per product and aggregate the results. Each sub-quote is independently audited.

---

## Common error codes

| Code                       | Meaning                                               | Returned by                                          |
| -------------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
| `ENTITY_NOT_FOUND`         | Entity does not exist or customer mismatch            | `quote()` / `estimate()` step 1                      |
| `ENTITY_CANNOT_TRANSACT`   | Entity lacks `TRANSACT` capability                    | step 1                                               |
| `PRODUCT_BLOCKED`          | Active block exists                                   | step 2                                               |
| `PRODUCT_INACTIVE`         | Non-routed product not active (legacy fallback)       | step 3                                               |
| `NO_ELIGIBLE_ROUTE`        | Routes exist but none is ACTIVE                       | step 3                                               |
| `LIMIT_EXCEEDED`           | A limit window or transaction bound was breached      | `quote()` step 6                                     |
| `CUSTOMER_CANNOT_ACTIVATE` | Customer tried to write `value: "APPROVE"`            | `ProductActivationRuleService.create/update`         |
| `INVALID_FEE_RULE`         | Customer-tier fee rule failed validation (negative)   | `ProductFeeRuleService.create/update`                |
| `INVALID_LIMIT_RULE`       | Customer-tier limit rule failed tightening guard      | `ProductLimitRuleService.create/update`              |
| `DUPLICATE_MATCHER`        | Live sibling rule in same scope has identical matcher | v2 rule services (DB unique index on `matcher_hash`) |
| `ROUTE_NOT_FOUND`          | External route ID does not exist                      | `ProductRouteAdminService` writes                    |

---

## Notes for legacy paths

`ProductConfigService.isActive()`, `ProductFeeConfigService.getFees()`, and `ProductLimitConfigService.getLimits()` remain as the non-routed-product fallbacks (`identity.v1`, `organization.v1`). The legacy receiver-overlay fee model (`CFX` / `PROVIDER` / `CUSTOMER` items, `CUSTOMER_RECEIVER_ONLY`, `DEACTIVATIONS_ONLY`) is **not** part of v2 service signatures. `legacyMappers.ts` exposes shape-compat helpers for code still consuming the old flat fee/limit format.
