Mark a product_quote row as accepted. Stamps accepted_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 filters accepted_at IS NOT NULL AND outcome <> 'FAILED'; a
failed quote no longer counts).
Re-checks the rolling-window limits first: quote() measured usage when
it issued the quote, and other quotes may have been accepted since. The
check holds a transaction-scoped advisory lock on the quote's limit scope
(its entity and product category), so two accepts in one scope cannot both
read usage before either is stamped. It measures against the limit rule
versions the quote pinned, product first, then category, as quote() did.
A guarded UPDATE then 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.
OptionalbypassLimits?: booleanSkips the limit re-check, for a quote issued with bypassLimits: the
Green Dot AuthCommit and reconciliation re-quotes and a settled inbound
ACH debit, where the money has already moved.
Optionaltx?: PgAsyncTransaction<NodePgQueryResultHKT, EmptyRelations>Runs the claim in a caller's transaction, e.g. a rail's accept. The limit-scope lock is then held until the caller commits.
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 one category limit rule search, which a category with no limit rules answers with nothing.
Many products for one entity resolve in the same fixed number of round trips as one — see resolve.
mode picks what a batch answers with. "EACH" (the default) returns one
result per request, positionally aligned with requests. "ALL" is
all-or-nothing: every output, or the single refusal REFUSAL_PRECEDENCE
ranks sharpest. Reach for "ALL" only where a partial set is useless to
the caller. A predicted LIMIT_EXCEEDED refuses neither — an estimate
never blocks, so it rides along on an ok result as
QuoteOutput.limitEvaluation.
No request carries an idempotencyKey: an estimate writes no row, so
there is nothing for a key to replay.
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 one category limit rule search, which a category with no limit rules answers with nothing.
Many products for one entity resolve in the same fixed number of round trips as one — see resolve.
mode picks what a batch answers with. "EACH" (the default) returns one
result per request, positionally aligned with requests. "ALL" is
all-or-nothing: every output, or the single refusal REFUSAL_PRECEDENCE
ranks sharpest. Reach for "ALL" only where a partial set is useless to
the caller. A predicted LIMIT_EXCEEDED refuses neither — an estimate
never blocks, so it rides along on an ok result as
QuoteOutput.limitEvaluation.
No request carries an idempotencyKey: an estimate writes no row, so
there is nothing for a key to replay.
Called when the transaction's on-chain debit fails: sets outcome to
INSUFFICIENT_FUNDS (Solana rejected it for balance) or FAILED (any
other failure), or INSUFFICIENT_BANK_ACCOUNT_FUNDS (a bank-account balance
rule refused the payout at send time; internal only), so the attempt stops
counting toward limits. Idempotent; a
missing, unaccepted or already-failed quote is a no-op.
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. Counts
every product_quote row stamped with accepted_at and a non-FAILED outcome
(a FAILED quote's debit failed), scoped to the entity.
The published shape of fetchUsage, which owns the read.
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_*, or a status label given to one) do not
write.
mode picks what a batch answers with. "EACH" (the default) denies per
item: the requests are independent, so one refusal leaves the rest issued,
and each element is exactly what a lone request would have got, down to
the audit row. "ALL" is all-or-nothing — every quote issued, or one
refusal standing for the batch and no OK row written, so no quote from a
refused batch exists to be accepted. A refusal still writes the rows that
record why; an INACTIVE or LIMIT_EXCEEDED row is that audit trail.
Quotes in one batch do not spend each other's limits — only an accepted quote moves a rolling window, and nothing here accepts.
Round trips are fixed in the request count on every path except a refused idempotency key, which costs a release and a reload for that input alone. Statement text is constant regardless, so RDS Proxy cannot pin on it.
"ALL" takes no idempotencyKey on any request: replay is defined
against a committed row, and across a batch that only some keys already
hold there is no one right answer. assertNoIdempotencyKeys
enforces that, because the overload's type cannot. Under "EACH", two
requests for one
product may still not share a key — see
assertDistinctIdempotencyKeys, which throws. That is a deliberate
carve-out from the sequential behaviour where a second call replays the
first's row: a batch has no single INSERT analogue of that, and handing
two requests one row id is worse than refusing. The key is
caller-supplied, so the first API surface over this method owes a
functional refusal code in place of the throw.
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_*, or a status label given to one) do not
write.
mode picks what a batch answers with. "EACH" (the default) denies per
item: the requests are independent, so one refusal leaves the rest issued,
and each element is exactly what a lone request would have got, down to
the audit row. "ALL" is all-or-nothing — every quote issued, or one
refusal standing for the batch and no OK row written, so no quote from a
refused batch exists to be accepted. A refusal still writes the rows that
record why; an INACTIVE or LIMIT_EXCEEDED row is that audit trail.
Quotes in one batch do not spend each other's limits — only an accepted quote moves a rolling window, and nothing here accepts.
Round trips are fixed in the request count on every path except a refused idempotency key, which costs a release and a reload for that input alone. Statement text is constant regardless, so RDS Proxy cannot pin on it.
"ALL" takes no idempotencyKey on any request: replay is defined
against a committed row, and across a batch that only some keys already
hold there is no one right answer. assertNoIdempotencyKeys
enforces that, because the overload's type cannot. Under "EACH", two
requests for one
product may still not share a key — see
assertDistinctIdempotencyKeys, which throws. That is a deliberate
carve-out from the sequential behaviour where a second call replays the
first's row: a batch has no single INSERT analogue of that, and handing
two requests one row id is worse than refusing. The key is
caller-supplied, so the first API surface over this method owes a
functional refusal code in place of the throw.
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 (productId, entity id, source/target currency,
cashDepositRetailerId, decimalPlaces) 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) it is frozen so
rolling-window usage stays stable: the pre-check below and the UPDATE's
accepted_at IS NULL predicate both answer QUOTE_ALREADY_ACCEPTED.
Deliberately exempt from customer status, unlike quote() and
estimate(): every request is systemInitiated, so the status rule's
customer clause never refuses it. Entity status still applies. Two of
update()'s three callers settle a quote already issued — settlement
bookkeeping on money that has already moved. The
third, greendotConsumptionEventActivities.ts's AUTH branch (:474),
authorizes new cash at the register against an already-issued barcode,
before any money has moved, so a disabled customer can keep depositing
against it. The ruling to leave update() exempt stands anyway:
gating AUTH without AUTH_COMMIT would strand half-committed deposits —
cash already taken at the register with nothing to apply it to. Do not
"fix" the inconsistency — see
docs/proposals/CUSTOMER_DISABLE_DELETE.md § 5.3 for what that costs.
Busts the entity reads an accepted or failed quote's usage reaches;
nullwhere nothing caches them.