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 |
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 (R01–R85). -
Card scheme (
cardCodes) — 93 Usio pinless decline codes (92 sheet rows;9Gis encoded alongside the sheet’s09/9G row because Usio sends the raw code9G).
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 (Usio NACHA scheme) |
|
Card (Usio pinless scheme) |
|
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
statusCodeis 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,AG01alone appeared asR10,R11,R20andR29, andNARRas bothR06and a non-coded "Invalid Data". SoresolveACHCodeuses two sources, in order:-
return_infoleading token (authoritative). JPM prefixes the NACHA R-code onto the info text, e.g."R10 CUST ADVISES NOT AUTH.".leadingRCodeparses that token; it covers every R-code and disambiguates codes likeAG01. -
jpmToUSIOISO-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 AM04R01Insufficient funds
AC04R02Account closed
AM07R09Uncollected funds
AC01R04Invalid account number
AC06R16Account frozen
DS02R08Payment stopped
BE01R03No account / unable to locate
AG07R07Authorization revoked
MD07R15Beneficiary deceased
FF02R17Questionable / file record edit criteria
AM05R24Duplicate entry
This is the same behavior documented in
spec/jpm_webhook_api.yaml. The JPM webhook processor storesstatusCode→return_codeandstatusText→return_infowithout 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 (theProhibitedfield on each code’sConfig), 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 optionalemailon 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 theblocklist-handlerLambda off the returned-payment Kinesis stream. ItsDispatchstep performs the bank action: remove the cached bank account (bankaccount.Service.Remove) and blocklist the user (Blocklister.Block, the sameBLOCKEDrecord 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 |
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 butenabled=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) andinvalidating_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 |
|---|---|---|
|
once per handled card decline |
|
|
card-invalidation save attempt |
|
|
every blocklist write (legacy and live paths) |
|
|
per-code comms attempt |
|
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 (HandleCardDeclinemarks the card invalid and saves it). -
iterable.API— fires the per-code Iterable comms event (mocked vianotifier.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 theBLOCKEDrecord, for both the legacy block and the live invalidate-bank action. Satisfied byblocklist.LegacyBlocklister, an adapter over the blocklist repository that writes the record theblocklist-handlerused 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).