Product service layer for the CFX platform. Resolves whether an entity can use a payment-rail product, what it costs, and whether usage limits allow the transaction — driven by the v2 rules engine across activation / fee / limit rules with admin and customer tiers.
Architecture and full quote flow: docs/PRODUCT_SERVICES.md.
import { ProductQuoteService } from "@cfxlabsinc/product-services";
const productQuoteService = new ProductQuoteService({ db, valkeyClient: null });
const request = {
productId: "withdraw.us_wire.v1",
sourceCurrency: "MOVEUSD",
targetCurrency: "USD",
amount: { source: new BigNumber(1000) },
} as const;
// Preview — evaluates limits without enforcing them, writes no audit row.
const [preview] = await productQuoteService.estimate({
customerId,
entityId: entity.externalId,
requests: [request],
});
// Transactional — enforces limits, writes a product_quote audit row.
const [result] = await productQuoteService.quote({
customerId,
entityId: entity.externalId,
requests: [{ ...request, idempotencyKey }],
});
if (!result.ok) {
// result.error.code:
// "CUSTOMER_NOT_ACTIVE" | "ENTITY_NOT_FOUND" | "ENTITY_CANNOT_TRANSACT" |
// "PAYMENT_INSTRUMENT_BLOCKED" | "PRODUCT_INACTIVE" | "NO_ELIGIBLE_ROUTE" |
// "AMOUNT_BELOW_FEE_FLOOR" | "NEGATIVE_FEE" | "LIMIT_EXCEEDED"
}
const { entity, route, fees, quote, limitEvaluation, quoteId } = result.value;
// quote.sourceAmount, quote.targetAmountAfterFees, quote.fees, quote.totalFees
Both methods take one entity and a list of requests, one per product, and answer one Result per request (mode: "EACH", the default) or a single all-or-nothing result (mode: "ALL"). Usage is read inside the service from accepted product_quote rows on every call; estimate() reports a predicted violation on limitEvaluation and still returns ok. entityType, countryCode, and hasIdentityDocument are derived from the entity load — callers don't pass them.
The package exports the route, vendor and product services, six leaf rule services (3 categories × admin/customer), the quote orchestrator and its admin reads, and an eligibility check.
| Service | Role |
|---|---|
ProductRouteAdminService |
Admin CRUD over product_route and its product_route_version rows. create() / update() / delete() / search() / history(). |
ProductRouteService |
Read-only search() returns, per request, every candidate route with the winning activation/fee/limit rule joined per route. |
ProductVendorAdminService |
Admin CRUD over product_vendor and its product_vendor_version rows. |
ProductAdminService |
Admin get() / search() / update() over product. productCategory is read-only. |
ProductService |
get() / search() over the products one entity can use, per the route engine, in one statement; ProductAdminService extends it and adds searchAccess() by entity or entity type. |
ProductActivationRuleAdminService |
Admin CRUD over product_activation_rule. No write guards. |
ProductActivationRuleService |
Customer CRUD. Write guard: value must be DENY (approving is admin-only) → CUSTOMER_CANNOT_ACTIVATE. |
ProductFeeRuleAdminService |
Admin CRUD over product_fee_rule. No sign guard. |
ProductFeeRuleService |
Customer CRUD. Write guard: rejects negative fixedFeeAmount / variableFeeBps → INVALID_FEE_RULE. |
ProductLimitRuleAdminService |
Admin CRUD over product_limit_rule. No tightening guard. |
ProductLimitRuleService |
Customer CRUD. Write guard: every column an admin rule sets must be set, at or inside it (max ≤, min ≥) → INVALID_LIMIT_RULE. |
ProductQuoteService |
Consolidated orchestrator. estimate() for preview; quote() for transactional + audit; accept() / update() on issued quotes. |
ProductQuoteAdminService |
Admin reads over product_quote: get() / search() / getRouteTiming(). |
ProductEligibilityService |
check() answers whether an entity can use a product and why not, gate by gate. Writes nothing and reads no usage. |
import {
calculateFeesForSourceAmount,
calculateFeesForSourceAmountAfterFees,
} from "@cfxlabsinc/product-services";
calculateFeesForSourceAmount — given a source amount and a ProductFee[] template, returns itemized fees and totalFees. Fixed fees deduct first, variable applies to the remainder.calculateFeesForSourceAmountAfterFees — back-solves: given a desired post-fee target amount, returns the source amount needed to deliver that target. Solved source is rounded up so the recipient is never short.Always use BigNumber — never plain JS arithmetic for fee math.
| Code | Meaning |
|---|---|
PAYMENT_INSTRUMENT_BLOCKED |
No route left, and the named payment instrument is BLOCKED |
CUSTOMER_NOT_ACTIVE |
No route left, and the customer is not ACTIVE (not for a systemInitiated request) |
ENTITY_NOT_FOUND |
Entity does not exist or customer mismatch |
ENTITY_CANNOT_TRANSACT |
No route left, and the entity is not APPROVED |
PRODUCT_INACTIVE |
No candidate route admits the request |
NO_ELIGIBLE_ROUTE |
Candidate routes exist, but none is ACTIVE or the bankId hint excludes them all |
AMOUNT_BELOW_FEE_FLOOR |
Post-fee target amount is ≤ 0 |
NEGATIVE_FEE |
Total fees are negative |
LIMIT_EXCEEDED |
A per-transaction or rolling-window limit was breached (quote() / update()) |
QUOTE_NOT_FOUND |
No quote with that id for this customer (accept() / update()) |
QUOTE_ALREADY_ACCEPTED |
The quote is already accepted (accept() / update()) |
QUOTE_NOT_OK |
The quote's outcome is not OK (accept()) |
QUOTE_EXPIRED |
The quote's expires_at has passed (accept()) |
CUSTOMER_CANNOT_ACTIVATE |
Customer wrote value: "APPROVE" on an activation rule |
INVALID_FEE_RULE |
Customer-tier fee rule had a negative component |
INVALID_LIMIT_RULE |
Customer-tier limit rule loosened or left unset a column the admin baseline sets |
DUPLICATE_MATCHER |
A (route, customer, type, matcher) row already exists |
ROUTE_NOT_FOUND |
External route ID does not exist |
LIMIT_EXCEEDED carries a ProductLimitViolation payload with { window, type, limit, usage } — usage is the source amount for a TRANSACTION bound, otherwise the window's usage before this quote.
docs/PRODUCT_SERVICES.md — schema, tier-precedence semantics, the quote() flow step-by-step, and edge cases.