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.
Bootstrap with createProductServices:
import { createProductServices } from "@cfxlabsinc/product-services";
const { productQuoteService } = createProductServices({ db, entityService });
// Stateless preview — no limit enforcement, no audit row.
const preview = await productQuoteService.estimate({
productName: "withdraw.us_wire.v1",
entityId: entity.externalId,
customerId,
sourceCurrency: "MOVEUSD",
targetCurrency: "USD",
amount: { source: new BigNumber(1000) },
});
// Transactional — enforces limits, writes a product_quote audit row.
const result = await productQuoteService.quote({
productName: "withdraw.us_wire.v1",
entityId: entity.externalId,
customerId,
sourceCurrency: "MOVEUSD",
targetCurrency: "USD",
amount: { source: new BigNumber(1000) },
getUsage: (input) => withdrawalUsageService.get({ entityId, input }),
getAggregateUsage: (input) => withdrawalUsageService.getAggregate({ entityId, input }),
});
if (!result.ok) {
// result.error.code:
// "ENTITY_NOT_FOUND" | "ENTITY_CANNOT_TRANSACT" |
// "PRODUCT_BLOCKED" | "PRODUCT_INACTIVE" | "NO_ELIGIBLE_ROUTE" |
// "LIMIT_EXCEEDED"
}
const { entity, route, fees, quote, quoteId } = result.value;
// quote.sourceAmount, quote.targetAmountAfterFees, quote.fees, quote.totalFees
getUsage / getAggregateUsage are TS-required on quote() to surface the limit-enforcement decision in code review. Pass null to opt out explicitly. entityType, countryCode, and hasIdentityDocument are derived inside quote() from the entity load — callers don't pass them.
The package exports two route services + six leaf rule services (3 categories × admin/customer) + two block services + the quote orchestrator.
| Service | Role |
|---|---|
ProductRouteAdminService |
Admin CRUD over product_route. create() / update() / delete(). No search() — that's a leaf-rule concern. |
ProductRouteService |
Read-only search() returns every route for the lookup tuple with the winning activation/fee/limit rule joined per route. |
ProductActivationRuleAdminService |
Admin CRUD over product_activation_rule. No write guards. |
ProductActivationRuleService |
Customer CRUD. Write guard: value must be INACTIVE (re-enabling 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. Customer can only add markup. |
ProductLimitRuleAdminService |
Admin CRUD over product_limit_rule. No tightening guard. |
ProductLimitRuleService |
Customer CRUD. Write guard: per-column tightening (max ≤ admin baseline, min ≥ admin baseline) → INVALID_LIMIT_RULE. |
ProductBlockAdminService |
Admin block CRUD on entity_product_block (type='ADMIN'). |
ProductBlockService |
Customer block CRUD (type='CUSTOMER'). Exposes isBlocked() — invoked by ProductQuoteService before route resolution. |
ProductQuoteService |
Consolidated orchestrator. estimate() for preview; quote() for transactional + audit. |
Legacy non-routed-product fallbacks (ProductConfigService.isActive(), ProductFeeConfigService.getFees(), ProductLimitConfigService.getLimits()) are retained for identity.v1 / organization.v1 and exported for completeness.
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 |
|---|---|
ENTITY_NOT_FOUND |
Entity does not exist or customer mismatch |
ENTITY_CANNOT_TRANSACT |
Entity lacks TRANSACT capability |
PRODUCT_BLOCKED |
Entity has an active admin or customer block |
PRODUCT_INACTIVE |
Non-routed product not active (legacy fallback) |
NO_ELIGIBLE_ROUTE |
Routes exist but none is ACTIVE for the lookup criteria |
LIMIT_EXCEEDED |
A per-transaction or rolling-window limit was breached |
CUSTOMER_CANNOT_ACTIVATE |
Customer wrote value: "ACTIVE" on an activation rule |
INVALID_FEE_RULE |
Customer-tier fee rule had a negative component |
INVALID_LIMIT_RULE |
Customer-tier limit rule loosened past the admin baseline |
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 snapshot value from getUsage for the offending window.
docs/PRODUCT_SERVICES.md — schema, tier-precedence semantics, the quote() flow step-by-step, and edge cases.