Stateless preview — resolves the route + fee template + amount math without writing an audit row and without ever blocking on limits. Use this for UI previews and any caller that has no intent to trade.
When amount is null returns the fee template + go/no-go (route
status); no concrete amounts are computed.
Limits are evaluated here too (Phase A), and the verdict comes back
on QuoteOutput.limitEvaluation as a prediction: a preview can
now tell the user "this exceeds your daily limit" instead of letting
them discover it at quote() time. Phase B — the LIMIT_EXCEEDED
error, the outcome stamp, the audit row — stays exclusively on the
quote/accept path. estimate() still returns ok on a predicted
violation; it is a preview, not a gate.
bypassLimits is hard-set to true internally and is not part of this
method's input. That flag now means only "do not enforce", which is
exactly right here. It still keeps the usage snapshot out of matcher
criteria, so usage-based matchers (usage30dUsd, etc.) evaluate as
unset and route selection is byte-identical to what previews have
always resolved.
Cost: this adds the rolling-window usage read to every preview, plus —
for products in a DEPOSIT/WITHDRAWAL category with an ACTIVE
aggregate route — one aggregate route search. Uncategorized products
(identity, organization, swap, virtual-account) short-circuit before
that second search.
Look up a single audit row by external id, scoped to the caller's customer. The audit table is append-only, so callers get a frozen snapshot of what the resolver decided at quote time — the on-disk row is the source of truth for replay, receipts, and downstream reconciliation.
Returns null when the row doesn't exist or belongs to a different
customer (treated as not-found per the service-pattern convention —
no QUOTE_NOT_FOUND error code surfaced).
Public read-only entry point for callers (admin dashboards, internal
reports) that need rolling-window usage outside the quote() flow.
Same query and semantics as the internal pre-fetch — counts every
product_quote row stamped with accepted_at, scoped to the entity.
Batched getUsage. Returns byte-identical per-product and aggregate-domain snapshots for every requested product, in a number of queries that scales with the number of category families touched rather than the number of products.
getUsage is one scan per product, which is fine for the quote path (one
product per quote) and an N+1 everywhere a surface wants a table: the
admin customer page asks for eleven products per organization and paid
eleven scans for figures that ten of them already computed. Products in
one family share a scan exactly — the family scope is the aggregate
bucket's scope, and each product's rows are a disjoint subset of it, so
one grouped scan produces the same numbers as eleven.
Query plan, for D deposit products + W withdrawal products:
product read resolving each name's product_categoryproduct_quote scan per distinct category present{11 products, 2 families} is 3 queries instead of 11.The category grouping reads product.product_category — never a
deposit. / withdraw. name prefix, which is a naming convention and not
data (see rules/cascade-product.md).
Products absent from the map are impossible: every requested name gets an
entry, and a name with no product row falls into the uncategorized group
where its scalar-subquery id resolves to NULL, yielding the all-zero +
aggregate: null result getUsage returns for it today. Duplicate names
collapse to one entry.
Transactional quote — same math as estimate(), plus per-product and
aggregate-domain limit enforcement, plus a write to product_quote for
end-to-end audit. Returns the inserted row's external id as quoteId,
which downstream consumers (deposit/withdrawal/swap) persist on their
own quote rows via db.helpers.productQuoteInternalId({ quoteId }) —
a scalar subquery that resolves the externalId string to the int FK
at insert time. Service interfaces stay string-only; ints are
confined to the DB layer.
Usage is fetched internally from product_quote (rows where
acceptedAt IS NOT NULL count toward the rolling windows).
Limits are split into two phases. Phase A evaluates them as a pure
read, on every call — no caller can switch it off. Phase B applies
what Phase A found: the LIMIT_EXCEEDED error, outcome, and the
violation_* columns. bypassLimits: true suppresses Phase B only; the
evaluated verdict is still recorded on the row's limitEvaluation, so a
bypassed quote that would have been blocked is visible after the fact
instead of leaving no trace.
Auditing scope: a row is written iff a candidate route was picked —
i.e. OK, LIMIT_EXCEEDED, or INACTIVE (winning activation rule's
value is INACTIVE). Pre-pick failures (PRODUCT_INACTIVE,
NO_ELIGIBLE_ROUTE, ENTITY_*, PRODUCT_BLOCKED) do not write.
Re-resolve a pending product_quote against new inputs and patch the
row in place — single audit row per logical transaction, mutated as
the deposit lifecycle learns more about it. Designed for floating
quotes (created with amount: null for a barcode-style placeholder)
that need to be settled later with the real amount + recomputed fees
Only the genuinely updatable inputs are exposed on the signature —
amount, regionCode, bankId, speed, bypassLimits. The static
dimensions (productName, entity id, source/target currency,
cashDepositRetailerId, decimalPlaces, idempotencyKey) are read
back from the existing row and merged in. Callers can't change which
customer / entity / product the quote belongs to, by construction.
Re-runs the full resolver pipeline, then UPDATEs the row's fee
snapshot, route version FKs, outcome, violation, and pinned criteria
columns. bypassLimits: true skips usage prefetch + enforcement
(e.g. AuthCommit settling the final amount after limits already
passed at Auth); otherwise a violation lands as outcome = LIMIT_EXCEEDED + a snapshot and the method returns LIMIT_EXCEEDED.
Once the row is accepted (accepted_at IS NOT NULL) the DB-level
trigger rejects every UPDATE — at that point the row is frozen so
rolling-window usage stays stable. Callers see QUOTE_ALREADY_ACCEPTED
surfaced from the pre-check below; the trigger backs that guard up.
Mark a
product_quoterow as accepted. Stampsaccepted_at = now()and surfaces typed errors when the quote isn't acceptable. Once the stamp lands, the row counts toward rolling-window usage (the read path filtersWHERE accepted_at IS NOT NULL).Single round-trip on the happy path — a guarded UPDATE flips only acceptable rows. On miss we re-SELECT to disambiguate the failure (NOT_FOUND vs ALREADY_ACCEPTED vs EXPIRED vs NOT_OK).
Idempotency: re-calling on an already-accepted quote returns
QUOTE_ALREADY_ACCEPTED. Callers that legitimately replay (e.g. Temporal activity retries) should map that code to success.