Return Code Handling

The return-code engine (pkg/returncodes) is a config-driven, processor-agnostic engine that maps a normalized ACH return code or card decline code to a per-code action plan. It moves the service away from the narrow, hard-coded 8-code blocklist write (see Blocklist) toward targeted, per-code handling of bad payment instruments.

The engine owns both the legacy blocklist block and the new config-driven actions; its GrowthBook flags toggle between them (see Dispatch and Flags). It defaults to the legacy block, so it introduces no behavior change until the flags are flipped. It runs on two paths: the blocklist-handler Lambda for ACH returns, and synchronously in the API for pinless card declines (see Card handling).

Source of Truth

The per-code action tables in pkg/returncodes/codes.go are mirrored verbatim from the "Delta sheet" (synced 2026-06-18), which is the canonical source for ACH and card handling decisions. Two tables are encoded:

  • ACH scheme (achCodes) — 70 NACHA return codes (R01R85).

  • Card scheme (cardCodes) — 93 Usio pinless decline codes (92 sheet rows; 9G is encoded alongside the sheet’s 09/9G row because Usio sends the raw code 9G).

Each entry encodes only the in-scope columns: the instrument action (invalidate bank for ACH, invalidate card for card) plus the per-code Iterable event. The sheet’s other action columns (suspend user/bank/card, switch-to-debit/ACH, retry-later, attempt-RCC, card updater, partial auth) are out of scope for this engine and are intentionally not represented. By convention only the "YES" cells are set on an entry; an absent boolean field is a "NO" in the sheet.

Schemes and Normalization

The engine operates on normalized codes so handling is independent of which processor produced the failure.

Payment method Scheme

ach

ACH (Usio NACHA scheme)

pinless

Card (Usio pinless scheme)

rtp

none — RTP has no instrument-level handling in v1

normalize(ctx, rawCode, rawInfo, paymentMethod) resolves a raw processor code to a normalized code + scheme. rawCode is the processor return_code (JPM statusCode / Usio decline code) and rawInfo is the processor return_info (JPM statusText).

  • JPM → Usio: JPM’s ISO 20022 statusCode is not a reliable NACHA R-code on its own — a single ISO code can stand in for several distinct R-codes. Over 180 days of production returns, AG01 alone appeared as R10, R11, R20 and R29, and NARR as both R06 and a non-coded "Invalid Data". So resolveACHCode uses two sources, in order:

    1. return_info leading token (authoritative). JPM prefixes the NACHA R-code onto the info text, e.g. "R10 CUST ADVISES NOT AUTH.". leadingRCode parses that token; it covers every R-code and disambiguates codes like AG01.

    2. jpmToUSIO ISO-code map (fallback). Consulted only when the info text carries no R-code prefix. It holds only the unambiguous ISO codes observed in production (one ISO code → one R-code); ambiguous codes (AG01, NARR) and non-failures (0000 "No Error") are intentionally omitted because they can only be resolved from the info text.

      JPM ISO code Usio code Meaning

      AM04

      R01

      Insufficient funds

      AC04

      R02

      Account closed

      AM07

      R09

      Uncollected funds

      AC01

      R04

      Invalid account number

      AC06

      R16

      Account frozen

      DS02

      R08

      Payment stopped

      BE01

      R03

      No account / unable to locate

      AG07

      R07

      Authorization revoked

      MD07

      R15

      Beneficiary deceased

      FF02

      R17

      Questionable / file record edit criteria

      AM05

      R24

      Duplicate entry

      This is the same behavior documented in spec/jpm_webhook_api.yaml. The JPM webhook processor stores statusCodereturn_code and statusTextreturn_info without translating; the engine does the normalization.

  • Unknown codes resolve to a safe default (Known=false) and are logged for follow-up — the engine never acts on a code it does not understand.

Action Plan

Resolve(ctx, target) is pure: it maps a Target (user id, raw code, raw info, payment method, an IsFloat flag, and the failed account / card id) to an ActionPlan and performs no dispatch. The plan mirrors the matched config: the instrument action (invalidate bank or card) plus the per-code comms event.

One policy concept layers on top of the sheet data:

  • Prohibited codes (R07, R08, R10) — must never be re-presented, for NACHA compliance. This is a policy attribute (the Prohibited field on each code’s Config), not a sheet column.

Comms are scoped to float payments

The per-code comms event fires only for float payments (Target.IsFloat). Subscription, loan, and prenote returns still get the instrument action (invalidate bank / card) — that protection applies to every payment type — but never the Iterable event, since the repayment-flow messaging only makes sense for floats. Resolve enforces this: a non-float target resolves Comms=false even when the matched Config sets it (the IterableEvent stays populated for logging). Callers set IsFloat from the payment’s loan id (see api.Payment.IsFloat / dynamo.Payment.IsFloatPayment).

The event identifies the user by email via the Iterable TrackEvent.Email field — a user-id-only event does not resolve to an Iterable profile, so the user id is deliberately left off. Its data fields carry only per-code context (return_code, scheme, description).

The email is resolved lazily, only when the engine is actually about to send a comms event (so no lookup happens on the common no-comms paths):

  • If the caller set Target.Email, that value is used. The card path populates it from the optional email on the payment request (debitRequest / creditRequest).

  • Otherwise the engine looks it up from the user service (users.GetUser). The ACH path always relies on this, since returned float payments carry no email.

If no email can be resolved (empty request field and a failed/absent lookup), the comms event is skipped — the instrument actions (invalidate bank / card) still run.

Dispatch and Flags

The engine has two entry points, because the two instruments fail on different paths:

  • ACH (async)Handle(ctx, target), called by the blocklist-handler Lambda off the returned-payment Kinesis stream. Its Dispatch step performs the bank action: remove the cached bank account (bankaccount.Service.Remove) and blocklist the user (Blocklister.Block, the same BLOCKED record the legacy path writes), forcing them through bank re-verification before another ACH attempt. The user’s Plaid item is left in place. Plus the per-code Iterable comms. Best-effort — each action is attempted independently, failures are logged and joined, but never fail the originating payment.

  • Card (synchronous)HandleCardDecline(ctx, target, card), called inline by the API on a pinless decline (see Card handling). It invalidates the failed debit card and fires comms.

Handle resolves the plan, then — gated by two GrowthBook flags — applies either the legacy blocklist block or the new config-driven actions, so the flags toggle cleanly between old and new behavior:

enabled log_only Effect

off (default)

Legacy block only — the exact pre-engine behavior: write a BLOCKED record for the structural codes (R02/R03/R04/R16 and their JPM ISO equivalents AC04/BE01/AC01/AC06). No new actions.

on

on (default)

Observe — log the resolved config-driven plan, but still apply the legacy block, so production behavior is unchanged.

on

off

Live — dispatch the new config-driven instrument actions; the legacy block no longer runs. Per-code comms are separately gated (see below).

A third flag, comms_enabled, independently gates the per-code Iterable comms once live. It defaults off, so a freshly-live engine invalidates instruments (remove bank / card, blocklist) but stays silent until comms are ramped. It is only consulted on the live path — the legacy and log-only paths never fire comms regardless — and only after any instrument action has already run, so gating comms off never affects the instrument outcome. A non-float ACH payment resolves Comms=false before the gate is reached, so it never consults the flag.

The flag keys are payments.return_code_handling.enabled, payments.return_code_handling.log_only, and payments.return_code_handling.comms_enabled. The intended rollout is: ship dark (enabled=off, legacy block runs), flip enabled=on with log_only=on to observe the resolved code→action mapping against the Delta sheet while the legacy block still protects users, ramp log_only=off once the mapping and return-rate impact are confirmed (live instrument actions, still no comms), then flip comms_enabled=on to start the user-facing emails.

The legacy block is matched on the raw code (directly, or via the JPM ISO statusCode map — never statusText), reproducing the historical blocklist-handler switch exactly. The structural codes carry a LegacyBlock flag on their Config; like Prohibited, it is a policy attribute, not a sheet column.

Observability

Every handled return logs one Info-level plan line, regardless of flag state, carrying the full resolved plan (user_id, raw_code, normalized code, scheme, known, is_float, prohibited, invalidate_bank, invalidate_card, comms, iterable_event). The message names the branch taken, so each rollout stage is verifiable from production logs (Debug is not visible in prod):

  • Resolved action plan, engine disabled applying legacy block — engine deployed but enabled=off.

  • Resolved action plan, log-only mode applying legacy block — observe stage; reconcile these against the Delta sheet before ramping.

  • Resolved action plan, dispatching actions — live ACH path.

  • Resolved action plan, handling card decline — synchronous card path, with two extra fields: live (config-driven vs. legacy rule) and invalidating_card (the decision actually applied).

Each live action also logs its outcome at Info on success (Removed bank account for return code, Blocked user for return code, Sent comms event) and at Error on failure, so a dispatched plan can be verified action-by-action. Skipped comms log why (Comms gated off, skipping comms event, Skipping comms event, no email resolved for user), and normalization holes surface as Warns (unknown code, using safe default, no scheme for payment method).

Metrics

The engine emits Datadog counters under the instrument domain (not an engine-specific namespace). Unknown codes tag as code:unknown to bound tag cardinality; the mode tag distinguishes legacy from config-driven behavior for rollout verification. Metrics fire only on real attempts — log-only "would-have" counts are logs-only (the plan line above).

Metric Fires Tags

payments.debitcard.declined

once per handled card decline

code, mode:live|legacy, action:invalidate|keep

payments.debitcard.invalidated

card-invalidation save attempt

code, mode, outcome:success|failure

payments.blocklist.action

every blocklist write (legacy and live paths)

action:blocked, mode:live|legacy, code, outcome:success|failure

payments.comms.event

per-code comms attempt

event, code, scheme, outcome:sent|failure|gated|no_email

The bank-account removal has no engine-side metric: bankaccount.Service.Remove already counts successes (payments.bankaccount.action action:deleted), and on the live path the removal always pairs with the blocklist write, so payments.blocklist.action{mode:live} carries the per-code volume. Upstream return volume is counted by payments.ach.returned (JPM webhook processor / Usio syncer), which serves as the ACH denominator; card declines use payments.debitcard.declined.

Return-Rate Safety

The engine must be net return-reducing to stay under the NACHA thresholds. Removing the bad account and blocklisting the user (invalidate bank) generates no ACH entry and therefore zero returns, whereas re-presenting a bad account generates a return. Prohibited codes are never re-presented, and invalidate-bank removes a bad account after its first return so it cannot generate a second. The service does not schedule re-presentments — retries remain owned by float/subscription within their own limits.

Card handling

Card invalidation runs synchronously in the API at payment time, not in the async engine — a pinless decline is known the moment USIO responds. pkg/api’s `checkResponseCode runs only on a real network decline (USIO returned a confirmation id; system errors / pre-network rejections carry none and must not touch the card), then calls HandleCardDecline. The source_id of the failed payment — added to the published payment event — identifies the instrument.

Card invalidation toggles like the ACH side: when live it invalidates per the config (InvalidateCard) and fires comms (subject to the same comms_enabled gate as the ACH path); otherwise it applies the legacy rule — invalidate unless the code is a known soft decline. The soft codes (51/57/5112/82) carry a LegacyKeepCard flag on their Config (a policy attribute, mirroring LegacyBlock), so the legacy path reproduces the pre-engine checkResponseCode allow-list exactly, including invalidating unknown codes.

Dependencies and Testing

The engine holds concrete dependencies (mocked in tests via mockery), not an abstract instrument interface:

  • bankaccount.API — the ACH bank action: remove the cached account that failed.

  • dynamo.DebitCardRepository — the card action (HandleCardDecline marks the card invalid and saves it).

  • iterable.API — fires the per-code Iterable comms event (mocked via notifier.Iterable).

  • user.API — resolves the user’s email for the comms event when the caller did not provide one. Called only when the engine is about to send comms.

  • Blocklister — writes the BLOCKED record, for both the legacy block and the live invalidate-bank action. Satisfied by blocklist.LegacyBlocklister, an adapter over the blocklist repository that writes the record the blocklist-handler used to write inline.

A given caller passes nil for the dependencies it doesn’t use (the async ACH path leaves the card repo nil; the API card path leaves bank/blocklist nil). Engine enablement is read directly from growthbook.API via the FlagEnabled / FlagLogOnly / FlagCommsEnabled flag keys.

The blocklist-handler Lambda builds the engine and routes every SUBSCRIPTION_RETURNED / FLOAT_DEBIT_RETURNED event through Handle, mapping the Kinesis payment to a Target (including its source_id as the failed account).