@cfxlabsinc/b2b-services
    Preparing search index...
    Index
    • 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 WHERE 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.

      Parameters

      • __namedParameters: { customerId: string; quoteId: string }

      Returns Promise<
          | { ok: true; value: { quoteId: string } }
          | { error: ServiceError; ok: false },
      >

    • 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.

      Parameters

      • input: Omit<ResolveInput, "bypassLimits">

      Returns Promise<
          | { ok: true; value: QuoteOutput }
          | { error: PreRouteError | QuoteInvariantError; ok: false },
      >

    • 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).

      Parameters

      • __namedParameters: { customerId: string; id: string }

      Returns Promise<ProductQuote | null>

    • 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.

      Parameters

      • args: {
            customerId: string;
            entityId: string;
            entityType: "IDENTITY" | "ORGANIZATION";
            productName:
                | "organization.v1"
                | "identity.v1"
                | "card.physical_card.v1"
                | "card.virtual_card.v1"
                | "deposit.us_cash.v1"
                | "deposit.rtp.v1"
                | "deposit.us_bank_ach.v1"
                | "deposit.ach_credit.v1"
                | "deposit.us_wire.v1"
                | "deposit.swift_wire.v1"
                | "transfer.redemption.v1"
                | "swap.v1"
                | "withdraw.blockchain.v1"
                | "withdraw.ke_bank.v1"
                | "withdraw.ke_momo.v1"
                | "withdraw.mx_bank_spei.v1"
                | "withdraw.swift_wire.v1"
                | "withdraw.tg_momo.v1"
                | "withdraw.us_bank_ach.v1"
                | "withdraw.us_instant.v1"
                | "withdraw.us_wire.v1"
                | "withdraw.ach_pull.v1"
                | "withdraw.us_wire_drawdown.v1"
                | "account.virtual-account.v1"
                | "deposit.*"
                | "withdraw.*";
        }

      Returns Promise<{ aggregate: UsageSnapshot | null; product: UsageSnapshot }>

    • 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:

      1. one product read resolving each name's product_category
      2. one product_quote scan per distinct category present
      3. one further scan covering every uncategorized product, if any So {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.

      Parameters

      • __namedParameters: {
            customerId: string;
            entityId: string;
            entityType: "IDENTITY" | "ORGANIZATION";
            productNames: readonly (
                | "organization.v1"
                | "identity.v1"
                | "card.physical_card.v1"
                | "card.virtual_card.v1"
                | "deposit.us_cash.v1"
                | "deposit.rtp.v1"
                | "deposit.us_bank_ach.v1"
                | "deposit.ach_credit.v1"
                | "deposit.us_wire.v1"
                | "deposit.swift_wire.v1"
                | "transfer.redemption.v1"
                | "swap.v1"
                | "withdraw.blockchain.v1"
                | "withdraw.ke_bank.v1"
                | "withdraw.ke_momo.v1"
                | "withdraw.mx_bank_spei.v1"
                | "withdraw.swift_wire.v1"
                | "withdraw.tg_momo.v1"
                | "withdraw.us_bank_ach.v1"
                | "withdraw.us_instant.v1"
                | "withdraw.us_wire.v1"
                | "withdraw.ach_pull.v1"
                | "withdraw.us_wire_drawdown.v1"
                | "account.virtual-account.v1"
                | "deposit.*"
                | "withdraw.*"
            )[];
        }

      Returns Promise<
          Map<
              | "organization.v1"
              | "identity.v1"
              | "card.physical_card.v1"
              | "card.virtual_card.v1"
              | "deposit.us_cash.v1"
              | "deposit.rtp.v1"
              | "deposit.us_bank_ach.v1"
              | "deposit.ach_credit.v1"
              | "deposit.us_wire.v1"
              | "deposit.swift_wire.v1"
              | "transfer.redemption.v1"
              | "swap.v1"
              | "withdraw.blockchain.v1"
              | "withdraw.ke_bank.v1"
              | "withdraw.ke_momo.v1"
              | "withdraw.mx_bank_spei.v1"
              | "withdraw.swift_wire.v1"
              | "withdraw.tg_momo.v1"
              | "withdraw.us_bank_ach.v1"
              | "withdraw.us_instant.v1"
              | "withdraw.us_wire.v1"
              | "withdraw.ach_pull.v1"
              | "withdraw.us_wire_drawdown.v1"
              | "account.virtual-account.v1"
              | "deposit.*"
              | "withdraw.*",
              { aggregate: UsageSnapshot
              | null; product: UsageSnapshot },
          >,
      >

    • 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.

      Parameters

      • input: ResolveInput

      Returns Promise<
          | { ok: true; value: QuoteOutput & { quoteId: string } }
          | {
              error: PreRouteError | QuoteInvariantError | LimitExceededError;
              ok: false;
          },
      >

    • 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

      • a fresh limit check.

      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.

      Parameters

      • __namedParameters: {
            customerId: string;
            data: {
                amount?: { source: BigNumber } | { target: BigNumber } | null;
                bankId?: string;
                bypassLimits?: boolean;
                regionCode?: string;
                speed?: Speed;
            };
            id: string;
        }

      Returns Promise<
          | { ok: true; value: QuoteOutput & { quoteId: string } }
          | {
              error:
                  | PreRouteError
                  | QuoteInvariantError
                  | LimitExceededError
                  | ServiceError<"QUOTE_NOT_FOUND" | "QUOTE_ALREADY_ACCEPTED">;
              ok: false;
          },
      >