---
status: in-progress
---

# Green Dot settlement reconciliation

> **Status: in progress.** Steps 0 to 2 of § 5 are built. `s3-event-handler` publishes
> `backoffice.greendotSettlementFile.created` for every settlement CSV that lands in the bucket
> (#4446, #4447), the batch and entry tables exist (#4713), and the historical backfill and
> reconcile are #4716. The admin batch explorer of step 5 is built (#4822, #4866). The live path
> (step 4) and the `MINTED_NOT_SETTLED` alarm are not built, so nothing consumes the file event
> yet. § 8's historical sweep has an unmerged script; § 10 is resolved. § 9 is audited but
> unwritten.
>
> **Every figure was remeasured on 2026-10-17** from a full read of the production settlement bucket
> (`cfx-greendot-prod`, 1,232 main settlement CSVs, 2023-04-25 → 2026-10-16, plus the one-off 2023 rerun backfill where marked) and read-only queries
> against the prod reader. The first draft measured to 2026-09-08; where a month of data or the
> stranded deposits' resolution changed a conclusion, the text says so. § 7 answers every question
> this document opened.

Green Dot settles cash deposits to CFX as one daily ACH credit covering a whole day of register
transactions. Those credits land on FIB (the FBO today, two other accounts before 2024-08-15, § 1)
and **none of them has ever been reconciled
to its settlement**: 403 credits, $4,949,742.90. 402 read `POSTED` with no reconciliation marker;
the 403rd carries a hand-entered operating-entry marker, not a settlement (§ 1).
Meanwhile Green Dot publishes a settlement file naming every transaction in the batch, and no code
in this repo reads it.

The gap is not a matching problem. It is that nobody has connected two records that already
join exactly.

## 1. Problem

`bank_transaction` carries 403 Green Dot settlement credits, described
`External Deposit GREEN DOT BANK <n> - GDB FR ACH CFX LABS INC. <n>`. Green Dot has paid into
three FIB accounts in turn, never two at once:

| Account     | `bank_account`              | Credits | Value         | Dates                   |
| ----------- | --------------------------- | ------- | ------------- | ----------------------- |
| `100099068` | Operating (`CFX_OPERATING`) | 6       | $180.00       | 2023-05-18 → 2023-09-08 |
| `100104801` | Legacy FBO (`CFX_FBO`)      | 25      | $1,608.00     | 2023-09-13 → 2024-08-14 |
| `100108059` | FBO (`CFX_FBO`)             | 372     | $4,947,954.90 | 2024-08-15 → 2026-10-16 |

One of the Legacy FBO's 25 reads `GREEN DOT BANK - PAYMENT` ($100.00, 2023-11-20) rather than
`GDB FR ACH`. Its amount ties to the 2023-11-16 rerun batch ($100.00, one entry), so it is counted
and matched like the rest, and recognition (§ 4.4) accepts it by an explicit id rather than widening
the description rule. No other bank carries a Green Dot row. 400 were written by the
MetCap sync up to its last run on 2026-10-09; 3 since then (2026-10-14 → 16) were entered by hand
(`raw.source = 'manual'`) while no feed runs. 402 are unreconciled.

The one that reads `RECONCILED` is `banktx_k33ybrFVkym0BVyfOHINw`, $40.00 posted 2023-05-23 to
the Operating account, marked on 2026-10-13 with `OPERATING_ENTRY` `opent_q1jdxHWUw4kHN5ElWgCpO` ("Operating expenses per
Cristen's sheet"). It is a settlement credit, not an operating expense: its amount ties to the
2023-05-22 rerun batch (two deposits, $40.00), one day before it. The 2023-05-18 batch is also two
deposits and $40.00 but five days back, outside the `[1,4]` window, so the match is unique once the
operating entry is removed. The backfill does not skip it as already reconciled. It
lists it as a leftover, and an operator removes the operating entry before the credit is matched.

They dominate the queue. Across the whole platform there are 745 unreconciled credits, and Green
Dot settlement is **402 of them — 54% by count**:

| Unreconciled credits | Count | Value          |
| -------------------- | ----- | -------------- |
| `fib`, Green Dot     | 402   | $4,949,702.90  |
| `fib`, all other     | 337   | $22,609,924.99 |
| `dart`               | 6     | $82,712.43     |

So this is not a niche gap. More than half the unreconciled-credit queue is one rail with a
settlement file that already resolves exactly.

Three consequences:

- **No settlement assurance.** CFX mints on `AuthCommit`, before Green Dot settles. Nothing
  computes whether the money arrived. Today the answer is good — **every cash deposit older than a
  week is paid for** (§ 7.5) — but nothing measures it, so a change in it would go unnoticed, and
  establishing it took an external reconstruction rather than a query.
- **The reconcile queue is unusable for cash.** Every cash deposit's funding credit is missing
  from the queue's working set, and the queue's own docs record that the omnibus rows swamp it.
- **A cash deposit cannot answer "which bank credit paid for this?"** Every other rail can.

`GreendotBarcodeConsumptionEventProcessor` and
[`CashDepositReconciliationService`](../../packages/deposit-services/src/cash/CashDepositReconciliationService.ts)
both stop at the authorisation lifecycle. Note the name collision: that service means _operator
rescues a stranded deposit_, not _bank credit is accounted for_. This document is about the
second, and never reuses the word for the first.

## 2. What is actually true

Sourced claims only, measured 2026-10-17. File figures come from the 1,232 main settlement CSVs in
the prod bucket (909 detail, 323 advice); database figures from read-only queries against the prod
reader. The 90 rerun objects are excluded from every figure except where § 2.1 describes them: the
design runs on main files, and the reruns are a one-off historical import (§ 4.2).

### 2.1 The files

| Claim                                                                                                                                                                   | Evidence                                                         |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Two paired families per day: `GDNSettlement_AdviceOfCredit_CFXLabs_<date>.csv` (the batch) and `GDNSettlement_TransmissionDetail_CFXLabs_<date>.csv` (its transactions) | 323 and 909 objects; delivered daily at 06:15 UTC                |
| **One header variant each across 3.5 years.** No schema drift                                                                                                           | every advice and every detail file carries the same header       |
| A detail file's sum equals its advice `Tot_File` **exactly, every time**                                                                                                | 323 pairs, 138 of them non-zero, zero mismatches                 |
| **The advice of credit is missing for 586 of 909 detail dates**, 235 of them carrying $1,485,551.54 of real money                                                       | Gap runs 2024-01-08 → 2026-04-06; advice files resume 2026-04-07 |
| `TransmissionID` (`Transmission ID NN-MMDDYYYY`) is 1:1 with a file date                                                                                                | 909 distinct; no transaction appears under two                   |
| The `TransmissionID` date part always equals `FileDate`                                                                                                                 | 15,854/15,854 rows                                               |
| A zero-settlement day still ships both files, with one filler row and `0.00` totals                                                                                     | e.g. 2026-09-07                                                  |

**The rerun files are a one-off backfill, not a recurring shape.** All 90 rerun objects (45 detail,
45 advice, half of them copies under a `CFXLabs Rerun/` prefix, some with a `TrannmissionDetail`
misspelling) cover 21 transmissions from 2023-05-17 → 2023-12-06. Green Dot re-sent them once: 61
rows ($2,626.00) across the copies, 28 distinct ($1,263.00). Nothing has arrived under a rerun name
since.

| Rerun fact                                                                                         | Evidence                                          |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Each rerun detail file sums exactly to its own rerun advice `Tot_File`                             | 21 of 21 transmissions                            |
| Every rerun `TransmissionID` also has a main advice file                                           | 21 of 21                                          |
| For 18 of them the main files shipped **empty** (main advice `0.00`); the rerun is the only record | 24 rows, $1,183.00                                |
| For the other 3 the main files had rows, and the rerun repeats 4 of them                           | 4 rows, $80.00, same `TransactionReferenceNumber` |

So the live design is built on the main files alone (§ 4.2), and the reruns come in exactly once,
deduped and merged by the historical backfill script (§ 4.2, § 5).

The bucket also holds two unrelated onboarding objects (a `.mov` and a `.png`); `receive` skips
anything that is not a main settlement CSV.

### 2.2 The join

| Claim                                                                                              | Evidence                                                                                    |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| **Two keys resolve every settlement row but two: 15,316 of 15,318**                                | joined against all 26,952 cash `deposit_quote` rows from prod                               |
| **`PartnerReferenceNumber` is `deposit_quote.external_id`**                                        | resolves 10,255 rows (66.95%), all from 2026-04-10 on                                       |
| **`BarcodeReferenceNumber` is the quote's barcode**                                                | resolves the remaining 5,061 (33.04%), i.e. every earlier row; the two rungs never disagree |
| The 2 unresolved are $20 test-sized rows in the 2023-08-09 file, the program's first weeks         | no cash quote carries either key                                                            |
| It is **not** `deposit_quote.reference`, which is a caller-supplied UUID                           | a `reference` rung matches 0 rows                                                           |
| `TransactionReferenceNumber` is globally unique                                                    | 15,318 of 15,318 distinct                                                                   |
| Every cash quote carries a barcode, and `deposit_quote_cash_barcode_number_idx` already indexes it | 26,952 of 26,952                                                                            |
| No transaction ever appears in two transmissions                                                   | 0 of 15,318                                                                                 |

#4707's `deposit_quote.barcode_number` column reached prod on 2026-10-17, backfilled on 26,951 of
26,952 cash quotes. The one gap is a quote created in the minutes between the backfill and the new
writer going live (status `ACCEPTED`, `data` set, column null). #4710 closes both remaining gaps:
a re-backfill for that row, and `deposit_quote_barcode_number_idx`, a partial index on the column
that replaces the expression index on `data ->> 'barcodeNumber'`. Rung 2 reads the column once
#4710 is in prod.

### 2.3 The credits

| Claim                                                                                                                                                                                                                                                                                                                                                  | Evidence                                                                                                                                                     |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **The credits are gross.** No fee netting                                                                                                                                                                                                                                                                                                              | 369 of 403 credits equal a batch detail sum to the cent                                                                                                      |
| They arrive 1-4 days after `FileDate`, mode +1                                                                                                                                                                                                                                                                                                         | offsets: +1 ×289, +3 ×59, +4 ×8, +2 ×6                                                                                                                       |
| Amount alone is **not** a key: only 291 of 373 batch totals are unique                                                                                                                                                                                                                                                                                 | small round totals repeat across days                                                                                                                        |
| Amount plus a `[1,4]` day window resolves **362 of 403 (89.8%)** uniquely                                                                                                                                                                                                                                                                              | 7 ambiguous, 34 unmatched; 18 of the 34 are 2023 credits whose transmissions shipped empty (§ 2.1)                                                           |
| All 7 ambiguities are $20-$500 totals repeated on consecutive days                                                                                                                                                                                                                                                                                     | measured only; § 4.4 queues every ambiguous credit for a person, and never picks the nearest date                                                            |
| 400 were produced by the **metcap scheduled sync** (`bank_id = 'fib'`, `raw.source = 'metcap'`), retired in #4620; its last row posted 2026-10-09. Since then 3 were entered by hand (`raw.source = 'manual'`, each matching its batch exactly). Its replacement, `fibTransactionSyncWorkflow` (`raw.source = 'fib_online_banking'`), is open as #4637 | [BANK_TRANSACTIONS.md § 3](../BANK_TRANSACTIONS.md), `statement.ts` `METCAP_BANK_ID`                                                                         |
| They carry no `methodData.method`, so `planBankTransactionReconciliation` returns `NOT_APPLICABLE` at its `if (!method)` guard and no arm ever runs                                                                                                                                                                                                    | [bankTransactionReconciliationActivities.ts:155-159](../../packages/bank-transaction-reconciliation-services/src/bankTransactionReconciliationActivities.ts) |

### 2.4 What production shows

| Measure                                        | Count     | Value         |
| ---------------------------------------------- | --------- | ------------- |
| Green Dot settlement credits                   | 403       | $4,949,742.90 |
| of which unreconciled                          | 402       | $4,949,702.90 |
| of which reconciled to a settlement            | 0         | $0            |
| Settlement batches with real rows              | 373       | $4,949,675.18 |
| Cash deposits `DEPOSITED` (gross)              | 15,375    | $4,962,780.83 |
| Minted but present in no main settlement file  | 63        | $14,286.97    |
| **of which genuinely unaccounted for (§ 7.5)** | **0**     | **$0**        |
| Settled but not yet credited                   | 4 batches | $8,169.48     |
| Cash deposits stranded `PROCESSING`            | **0**     | —             |
| Cash deposits `CANCELLED`                      | 210       | $68,089.47    |

Of the four uncredited batches, one is in flight (2026-10-16) and one (2026-10-09, $2,940.90) is the
MetCap cutover: a credit of exactly that amount posted 2026-10-09, the day of the file rather than
one to four after, so the `[1,4]` window misses it. A third is a reversal: the 2025-11-17 batch
totals $330.28 and a **FIB debit of exactly $330.28 posted on 2025-11-28**. Settlement reversal is
therefore a real class, discovered in the data rather than predicted from the specification. The
fourth (2025-10-14, $211.46) has no credit at all.

Resolving every settlement row against the deposit it names found **37 deposits Green Dot had paid
for that were still `PROCESSING`** ($6,003.34): 35 of them the USDC cohort of § 10, stalled waiting
on a swap. All 37 have since been worked by hand, deposit by deposit, and **none remain**. What the
episode leaves for the design is the rule it was worked by, which `SETTLED_NOT_DEPOSITED` (§ 4.6)
inherits:

- **Settlement is evidence, not a trigger.** It proves the cash arrived; it says nothing about what
  CFX already delivered.
- **`receipt_id IS NULL` does not mean the mint never happened.** `receiptId` is persisted only at
  the workflow's final step, so a run that minted and then died leaves precisely this row. The
  authoritative answer is on chain: `getIdempotencyKeyTransactionSignature` reads the mintfx
  program's idempotency PDA keyed on the deposit's `idempotencyKey`, which is why
  [`CashDepositReconciliationService.fail`](../../packages/deposit-services/src/cash/CashDepositReconciliationService.ts)
  consults it rather than trusting the row.
- **One deposit at a time.** A stall can sit under an open recovery with a customer, and re-running
  settlement underneath it delivers twice.

Settlement rows with no CFX deposit behind them are `UNMATCHED_NO_DEPOSIT` (§ 4.6): 8 once the
reruns are imported, 7 of them at the quote and 1 at the deposit. Of the 7, 2 are the main-file
test rows of § 4.3.

## 3. The model

**The settlement file is a self-proving manifest.** It carries its own control total and an exact
CFX join key, which no other feed here does. So this is not "guess which deposits a credit
covers" but "verify a manifest that already resolves".

**Ingest is separate from reconciliation.** A settlement entry is an external fact that exists
whether or not CFX can resolve it to a deposit. It is recorded either way, verbatim, and matched
afterwards. That is the inbox shape [CASH_DEPOSIT_BARCODE_STATE.md § 4.1](CASH_DEPOSIT_BARCODE_STATE.md)
proposes for consumption events, applied to settlement.

**Nothing auto-heals.** Money is already minted by the time a settlement file arrives, so an
automated write on an anomaly can only compound an error. Every unresolved case is classified and
queued for a person.

**No new producer.** The credit row is written by the FIB feed, whichever one is live. This design
adds a reconciler only, and never writes a `bank_transaction` row of its own.

**Reconciliation never moves money.** It reads, matches, stamps and queues. It never mints, never
sweeps a fee and never releases a deposit. Fees are out of scope entirely: the credits are gross,
and since #4695 a deposit's mint pays `FEE` directly, so there is no fee in the settlement path to
account for. The one fee action left is § 8's one-off sweep of historical residue, which is a
separate operator step in § 5 and not reconciler behaviour.

**Every deposit points at the file that settled it.** A cash deposit carries the settlement batch
that named it (§ 4.1), so "which Green Dot file paid for this?" is one column on the deposit, not a
join through the entry table. That id is CFX-internal: admin surfaces only.

## 4. Design

### 4.1 Two tables

`b2b.greendot_settlement_batch`, one row per `TransmissionID`:

- `transmission_id` **unique** — the idempotency linchpin. Re-ingesting any file is a no-op
  upsert.
- `file_date`, `settlement_account`, `program`, `partner`, `processor`
- `entry_count`, `total_amount` — derived from entries, the authority
- `advice_total_amount` — from the advice file when present, `NULL` for the 586 dates without one
- `status`
- `detail_object_key`, `advice_object_key` — **the table indexes the bucket.** Each names the S3
  object the batch was read from, and each is unique, so a key resolves to exactly one batch and an
  operator goes from a file to its batch, or a batch to its file, in one lookup. Every ingest sets
  them, live or backfill; for the 21 transmissions the 2023 reruns filled, they name the copies
  under `CFXLabs Rerun/` (§ 4.2). The detail key is nullable only because an advice file can land
  before its detail file; the detail ingest fills it in.

`b2b.greendot_settlement_entry`, one row per detail line:

- `transaction_reference_number` **unique** — a redelivered file upserts to itself
- `batch_id`, `amount`, `trans_date`, `trans_time`
- `partner_reference_number`, `barcode_reference_number`, `customer_reference_number`,
  `pin_reference_number`, `partner_account_number`, `product_type`
- `deposit_quote_id`, `deposit_id` — nullable FKs, resolved by § 4.3
- `match_status` (`MATCHED` | `UNMATCHED`) and `match_method` (`PARTNER_REFERENCE` |
  `BARCODE`, null when unmatched): the ladder's result and nothing else. Exception classes are
  never stored (§ 4.6)
- `raw` jsonb — the verbatim CSV row, so a re-match after a parser fix never re-reads S3

FK columns rather than the ACH pattern (`deposit_quote.data ->> 'achBatchId'`) because CFX
constructs ACH batches and assigns deposits into them, whereas Green Dot asserts this grouping.
An unresolvable assertion is honest as a row with a null FK; it is unrepresentable as a jsonb
pointer on a deposit that was never found.

Both tables carry the external-id copy of every FK, per #4424: `greendot_settlement_entry` holds
`batch_external_id`, `deposit_quote_external_id` and `deposit_external_id` beside the ids, under
composite FKs.

**The deposit's pointer to its file.** `b2b.deposit` gains two nullable columns:

- `greendot_settlement_batch_id`, `greendot_settlement_batch_external_id` (`gdsb_…`), under a
  composite FK to `greendot_settlement_batch (id, external_id)`, indexed on the external id.

One batch is one `TransmissionID` is one detail file, and the batch row holds
`detail_object_key`, so a deposit reaches the CSV in S3 in one hop. A scalar is safe because no
settlement row has ever appeared in two transmissions (§ 2.2, 0 of 15,318): a deposit has at most
one file. It goes on `deposit` rather than `deposit_quote` because only a deposit is money that
moved.

| Deposit population                                 | Pointer                                                                 |
| -------------------------------------------------- | ----------------------------------------------------------------------- |
| named in a transmission file (15,334 today)        | that file's batch                                                       |
| credited individually, outside any file (§ 7.1)    | null; its credit link is `DEPOSIT_IDS` (§ 4.5)                          |
| minted, not yet in any file                        | null; this null is what `MINTED_NOT_SETTLED` selects on                 |
| `PROCESSING` but settled (`SETTLED_NOT_DEPOSITED`) | the batch: the pointer records what Green Dot asserted, not CFX's state |

The column is written only by the match ladder (§ 4.3), in the same transaction that sets
`greendot_settlement_entry.deposit_id`, so the two can never disagree. The one other writer is a
re-uploaded detail dropping an entry (§ 4.2), which clears the pointer in the same statement that
deletes the entry, so a deposit never points at a batch that no longer names it. **A cancel does
not clear it.** The pointer records what Green Dot asserted, which a cancel does not change; `unreconcile`
still strips the deposit from `DEPOSIT_IDS`, and `SETTLED_BUT_CANCELLED` is exactly "pointer set,
status `CANCELLED`".

**Not through `DepositAdminService.update`.** That method opens its own transaction, takes no
outer `tx`, and fans out after its write (status events, cache busts), so it can neither share the
ladder's transaction nor stay quiet across a 15,000-row backfill. The pointer is settlement
bookkeeping, not a deposit lifecycle change, so the ladder sets it with one `UPDATE … FROM` over the
resolved entries inside its own transaction, in `greendot-settlement-services`, and publishes
nothing. _(My call.)_ The public `Deposit` shape already omits the two columns (#4713).

**Admin surfaces only.** The transmission is CFX-internal. `GET /v1/deposit`, the customer and bank
dashboards and every webhook keep their current shape; `mapDepositFromDb` maps the pointer onto an
admin-only field, and the public interface schema does not list it. The admin deposit detail shows
the `TransmissionID`, the file date and a link to the batch.

### 4.2 Ingest

**Driven by the event that already exists.** `s3-event-handler` calls
`GreendotSettlementFileAdminService.receive` for every object created in `cfx-greendot-<stage>`, and
it publishes `backoffice.greendotSettlementFile.created` `{ key, kind, size, versionId }` on the
internal bus for each settlement CSV. Nothing is stored at that point and nothing consumes it today.

**Narrow `receive` to the main families.** Its pattern today also admits the 2023 rerun names
(`GDNSettlementReports_…`, `TrannmissionDetail`), written when reruns looked like a recurring shape.
They were a one-off (§ 2.1), so the pattern becomes
`^GDNSettlement_(AdviceOfCredit|TransmissionDetail)_CFXLabs_\d{4}-\d{2}-\d{2}\.csv$` at the bucket
root, and anything else is `SKIPPED`. The live path is built on main files alone; history,
reruns included, comes in through the backfill script below, once. A rerun in future would show up as a skipped object to
look at, not as rows merged silently into a past batch.

```
S3 put ─► s3-event-handler ─► greendotSettlementFile.created (SNS)
                                        │
                     backoffice-api reconciliationHandler (claim on envelope.id)
                                        │
                     start ingestGreendotSettlementFileWorkflow
                     id = greendot-settlement-file-<key>-<versionId>
                                        │
               read CSV ─► parse ─► Tot_File check ─► upsert batch + entries
                                        │
                     match ladder (§ 4.3) ─► stamp deposits ─► try credit (§ 4.4)
```

- **The consumer** is a new arm in `backoffice-api`'s `reconciliationHandler`, the money-path SNS
  function. It already has its own DLQ, alarm and DynamoDB idempotency claim, which is what makes an
  SNS redelivery a no-op. The arm only starts a workflow, through `idempotentStart`, the way
  `startBankTransactionReconciliationWorkflow` does; it never awaits the run.
- **The workflow id** is the object's key plus `versionId`, so a redelivered event attaches to the
  run already going, while a re-uploaded object (new version) is read again.
- **A run reads the object's current version, not the event's.** It fetches the key without a
  `versionId`, so two runs for two versions of one key both apply the newest content, and an old
  event delivered late cannot overwrite a newer upload. The batch row lock serialises them; the
  second is a no-op.
- **One file is one transmission, and one transmission is one main file.** All 909 main detail
  files carry exactly one `TransmissionID`, and no transmission spans two main files, which is what
  makes `detail_object_key` unique. The reruns break the second half (three 2023-05 transmissions
  have two distinct rerun files each, under two naming schemes), so the backfill dedupes them by row
  first (step 2 below) and records one of them as the key; live ingest reads main files only.
- **Which file a run handles:** the event names one object. A `TRANSMISSION_DETAIL` run upserts the
  batch, its entries and `detail_object_key` (the event's `key`). An `ADVICE_OF_CREDIT` run upserts
  the batch header, `advice_total_amount` and `advice_object_key`, and never touches
  `entry_count` or `total_amount`. Order between the two does not matter: both upsert on
  `transmission_id`, and the `Tot_File` check runs whenever both halves are present, from whichever
  run arrives second.
- **The two runs for one transmission are serialised.** Both take a row lock on the batch
  (`INSERT … ON CONFLICT DO UPDATE` then `SELECT … FOR UPDATE`) before reading the other half, so
  the second run always sees the first's write and exactly one of them performs the `Tot_File`
  check.
- **Ingest records; it never matches.** A run writes only the settlement tables. Nothing downstream
  of a batch (the entry ladder, the deposit pointers, credit matching) runs until the batch is
  **complete**: its detail file is in, and either its advice file is in and its sum ties to
  `Tot_File`, or the date is one of the 586 historical dates that never had an advice file. Every
  detail date since 2026-04-07 has had its advice (139 of 139), so live, a detail that lands first
  simply waits for its pair. `status` carries this: `PENDING` until both halves are in, `COMPLETE`
  once they tie, `TOT_FILE_MISMATCH` when they do not. Whatever moves a batch into `COMPLETE` or
  `COMPLETE_WITHOUT_ADVICE` (the second ingest run, or the daily pass's promotion below) runs the
  entry ladder on it in the same transaction, so a complete batch always has its deposit pointers
  before any credit can claim it. So does any detail run on a batch that is already complete and
  not yet credited: the ladder runs over every entry the run wrote, so a re-uploaded file that adds
  rows to a `COMPLETE_WITHOUT_ADVICE` batch points their deposits before the new total can match. A
  mismatched batch is never matched against,
  stamps nothing, and is an exception class of its own (§ 4.6), so a bad pair is held where an
  operator sees it rather than written through and rolled back.
- **A missing advice delays, it does not block.** Green Dot stopped sending advice files once
  before, for 27 months (2024-01-08 → 2026-04-06), so its absence has to be survivable. A detail
  still unpaired one weekday after its file date becomes `COMPLETE_WITHOUT_ADVICE`: it proceeds as
  the 586 historical dates do, matched on its own entry sum, and `ADVICE_MISSING` (§ 4.6) records
  that it was never cross-checked and alarms. If the advice lands later it is still checked, and a
  mismatch then moves the batch to `TOT_FILE_MISMATCH` and the alarm fires on it. _(My call: one
  weekday, because the advice has always arrived with its detail at 06:15 UTC when it arrives at
  all, so a day's wait is already an outage.)_
- **Idempotency is in the data:** `transmission_id` and `transaction_reference_number` are unique, so
  a redelivered or re-uploaded file upserts its rows to themselves.
- **A re-uploaded detail replaces its batch's entries, until the batch is credited.** An upsert
  alone would keep a row the new version dropped, so a `TRANSMISSION_DETAIL` run deletes the batch's
  entries absent from the file it read, clears `greendot_settlement_batch_id` on any deposit those
  entries had resolved to (a `DELETE … RETURNING deposit_id` feeding the `UPDATE`), recomputes
  `entry_count` and `total_amount`, and re-runs the `Tot_File` check, all under the batch's row
  lock. A deposit it un-points is back in `MINTED_NOT_SETTLED`'s population. Once a credit carries the batch's
  `GREENDOT_BATCH`, its entries are frozen: a version that differs is not applied, and the run emits
  one structured line for the alarm (§ 4.6) instead. _(My call: a paid batch's entries change only
  by an operator's hand. Its claim can still be released, by the two paths of § 4.5.)_
- **A run never overwrites a batch from a different file.** If the batch's `detail_object_key`
  (or, for an advice run, `advice_object_key`) is already set to another object, the run writes
  nothing and logs the conflict. This is what protects the 21 transmissions the backfill filled from
  the reruns: their main files under the same `TransmissionID` shipped empty, and a re-sync event
  for one of those empty files must not delete the merged entries or record a `0.00` advice total
  that would read as `TOT_FILE_MISMATCH` and unwind the credit. Replacing a batch's file is an
  operator action, not an ingest side effect.
- **Filler rows are never entries.** A zero-settlement day ships one filler row, and all 536 filler
  rows in the bucket have an empty `TransactionReferenceNumber` and a `0.00` amount; upserting them
  would collide every one on `''`. Ingest drops a row with an empty reference before the upsert, so
  a zero day writes a batch with `entry_count = 0` and `total_amount = 0` and no entries.
- **Validation is per file pair, before anything is matched.** A detail file is checked against the
  advice file of the same date: its parsed sum must equal `Tot_File`. A mismatch marks the batch
  `TOT_FILE_MISMATCH` and stops it there; it catches a bad parse before any deposit or credit is
  touched. The batch's `total_amount` is the sum of its entries, and that is the figure a credit
  matches against (§ 4.4), so the 586 dates without an advice file still match.

**History goes through one throwaway script, not the live path.** The live path starts from the
file event and handles one object at a time; three and a half years of history is a batch job with
a known, finite input, and is simpler done once in order. The script is
`packages/greendot-settlement-services/scripts/backfill-greendot-settlement.ts`, it is deleted in
the PR after it runs, and it does four things:

```
1 create  ─►  2 backfill  ─►  3 reconcile the trivial  ─►  4 report the leftovers
  tables       main + reruns     exact matches only           a list for a person
```

1. **Create the tables.** The migration ships ahead of the script. The script needs only that the
   tables exist: every write upserts on the unique keys, so it can be re-run, and a second run
   over the same bucket changes nothing.
2. **Backfill, with the reruns deduped.** It lists the bucket, parses every main settlement CSV and
   every rerun, and builds one entry set in memory before writing anything:
   - **Reruns are deduped twice.** By object: the 45 rerun detail objects are 24 distinct files
     (the `CFXLabs Rerun/` prefix and the misspelt `TrannmissionDetail` name are byte-identical
     copies), and likewise for advice. By row: those files carry 61 rows but 28 distinct
     `TransactionReferenceNumber`s, and no reference appears with two amounts.
   - **Then merged into main on the same key.** The 4 references a main file already lists ($80)
     are dropped from the rerun side; the 24 new ones ($1,183) join the batch of their
     `TransmissionID`, whose `detail_object_key` and `advice_object_key` then point at the
     rerun copies under `CFXLabs Rerun/`.
   - **Validated per source before any write.** Each main detail file against its main advice (323
     pairs tie); each deduped rerun transmission against its own rerun advice (21 of 21 tie). The
     main advice reads `0.00` for 18 of those transmissions, so it is never the check for a rerun.
   - **A merged batch takes the rerun advice as its `advice_total_amount`.** A rerun is a complete
     re-issue of its transmission, not a supplement: in the 18 transmissions whose main file
     shipped empty it is the only content, and in the other 3 (2023-08-09, 08-31, 09-07) it repeats
     the main file row for row, which is where the 4 duplicate references come from. So the merged
     entry sum equals the rerun advice's `Tot_File` in all 21 (adding the two advices would count
     those 3 twice), every merged batch writes `COMPLETE`, and one that did not tie would write
     `TOT_FILE_MISMATCH` and be listed, never stamped. Its `advice_object_key` names the rerun
     advice, matching what was checked.
   - **Written in one transaction**, then the match ladder (§ 4.3) runs over every entry, setting
     each deposit's batch pointer.
3. **Reconcile the trivial ones.** Only exact, unique matches are stamped: a credit whose amount
   ties to exactly one unclaimed batch inside the `[1,4]` window (380 of 403 on 2026-10-17), and
   every entry the ladder resolved. Stamping writes reconciliation markers and pointers and moves no
   money, so "nothing auto-heals" does not forbid it. The script stamps through the same function
   the router arm uses (§ 4.5), so there is one stamping path.
4. **Investigate the leftovers.** Everything that is not trivial is written out, not acted on: the
   16 credits matching no batch with their suggested deposits (§ 4.4), the 7 ambiguous credits with
   their candidate batches, the 4 uncredited batches, the 8 entries with no deposit, and the 6
   amount mismatches. Each row names its § 4.6 class, so the run's counts can be checked against
   that table before anything else is trusted. A person works the list; anything they resolve goes
   through the admin queue (§ 5) like any live exception.

The script never starts a workflow, never publishes the file event, and is not wired to anything.

**No file falls between the backfill and the live path.** Green Dot delivers daily, so files land
while the script runs and before the consumer is on. The handoff is ordered so the two overlap
rather than leave a gap:

0. Deploy the unique partial index on `GREENDOT_BATCH` (the follow-up migration of § 4.5) before
   either path can stamp a credit, so concurrent stamps of one batch fail at the database rather
   than race. Live ingest never stamps (it only records), but the router arm and the backfill both
   do.
1. Enable the live consumer first (§ 5 step 4). From then on every new file is ingested as it lands.
2. Run the backfill over the whole bucket. A file the consumer already took is an upsert to itself.
3. Re-run the script once more after it finishes, which catches any file that landed during the
   first run before the consumer was healthy.

Overlap is safe because both paths write through the same upserts on `transmission_id`,
`transaction_reference_number` and the object keys; a gap is not, because nothing re-lists the
bucket afterwards. _(My call, reversing the earlier "backfill before the live consumer".)_

`AchFileS3Service` is the template for the S3 read. It already distinguishes `S3_OBJECT_NOT_FOUND`
from `S3_READ_FAILED`. `papaparse` is the repo's CSV parser; the `csv` package is used only for
`stringify`.

### 4.3 The match ladder

Two rungs, both exact equality against a unique indexed column. Measured against all 26,952 cash
quotes in prod, they resolve **every settlement row but two**:

| #   | Key                      | Target                                                   | Resolves             |
| --- | ------------------------ | -------------------------------------------------------- | -------------------- |
| 1   | `PartnerReferenceNumber` | `deposit_quote.external_id`                              | 10,255 (66.95%)      |
| 2   | `BarcodeReferenceNumber` | `deposit_quote.barcode_number` (#4707; indexed by #4710) | 5,061 (33.04%)       |
|     |                          |                                                          | **15,316 of 15,318** |

There is deliberately **no fuzzy rung**. An earlier draft proposed falling back to
`deposit_quote.reference` and then to identity-plus-amount-plus-date; both were dropped because
the first two rungs leave nothing for them to do. Rung 1 covers 2026-04-10 on, rung 2 covers
everything before it, and the two never disagree where both resolve. The 2 they miss are $20
test-sized rows in the 2023-08-09 file that no quote carries either key for; they are
`UNMATCHED_NO_DEPOSIT` history, not a gap a heuristic should fill.

A resolved entry sets `entry.deposit_quote_id`, `entry.deposit_id` and the deposit's
`greendot_settlement_batch_id` (§ 4.1) in one transaction. A quote with no deposit row resolves the
quote and leaves the deposit side null.

**Each rung must resolve to exactly one quote.** `external_id` is unique. The barcode is not
enforced unique: `deposit_quote_barcode_number_idx` (#4710) is a plain partial index, though prod
holds 0 duplicate cash barcodes on 2026-10-17. So rung 2 resolves only when exactly one cash quote
carries the barcode; two or more is `UNMATCHED` with the candidates recorded, never a pick.

If a future row does miss both rungs it is `UNMATCHED` and queued. It is never guessed at. That is
the ordering [`WireEmailBankTransactionService.reconcile`](../../packages/wire-email-services/src/WireEmailBankTransactionService.ts)
documents for the same hazard: an unlinked record sits on a queue, a wrongly-linked one silently
double-counts money.

**Compare amounts gross to gross.** `deposit.amount` is the wrong operand: 96 rows between
2024-05 and 2025-10 store it net of the CFX fee, so a naive comparison reports a mismatch on a
correct deposit. The differences are a small set of fixed fees (3.20 ×30, 3.09 ×15, 4.50 ×15,
3.00 ×12, and four others) and there are **none after 2025-10-21**, so this is a closed historical
era rather than live behaviour — § 7.6 names the commit that closed it. Compare against the quote's
`sourceAmount`, which is gross in every era, legacy rows included.

`sourceAmount` is the **authorised** amount rather than the settled one: `AuthCommit` re-quotes but
writes the settled figure back to neither the quote nor the deposit. So the comparison is still the
right one and `AMOUNT_MISMATCH` has a real job — it catches a register that settled a different
amount than it authorised. It has caught **6**: five 2024 entries that settled $20.00 against
quotes of $23.51 and $24.50 (the register took the net), and one 2025-05-01 entry whose quote reads
`24.490000000000002`, a float written as a string. The comparison must parse both sides as decimals
rounded to cents, or that last one is a false positive forever.

### 4.4 Credit to batch

```
bank_id = 'fib' AND account_number IN ('100099068', '100104801', '100108059') AND type = 'CREDIT'
AND amount = batch.total_amount
AND posted_at::date - batch.file_date BETWEEN 1 AND 4
```

plus a **one-to-one constraint**: a batch already claimed by another credit is not a candidate.
Measured on all 403 credits this yields 362 unique, 7 ambiguous, 34 unmatched. Ambiguous is
queued, never guessed. 18 of the 34 are 2023 credits for transmissions whose main files shipped
empty (§ 2.1). The rerun import gives those 18 their batches, so once the backfill has run the
figures are **380 unique and 16 unmatched**; live, on main files alone, every credit since 2024 has
a main batch to match.

**Unique means unique over a complete window.** A credit whose amount ties to one batch today may
tie to a second once a missing file date in its window is ingested, and with $20-$500 totals
repeating on consecutive days (the 7 ambiguous credits) that is not hypothetical. So a credit is
matched only when every **weekday** file date in `[posted − 4, posted − 1]` has a `COMPLETE` or `COMPLETE_WITHOUT_ADVICE` batch
(§ 4.2), a zero-total one included. A row the advice run created ahead of its detail is `PENDING`
and does not count: until its detail is in, its total is not known, so it could still be the
competing match. Green Dot ships one transmission per weekday and none on Saturday or
Sunday: every one of the 362 dates since 2023-04-25 with no file is a weekend day, and every weekend
day is one of them, so a weekday is never legitimately absent. Until the window is complete the
credit waits, not queues: the next file's ingest or the daily pass retries it. A weekday that stays
missing past the window is itself the exception, reported against the credit. The backfill meets
this by construction, because it ingests every file before it reconciles anything.

**A blocked window alarms, it does not wait forever.** Three conditions hold a weekday open: no
batch row at all (a lost event, a failed run, or a day Green Dot skipped), `PENDING` (the advice
came, the detail did not) and `TOT_FILE_MISMATCH`. A credit whose window is still
incomplete four weekdays after it posted is classed `CREDIT_WINDOW_BLOCKED`, naming the blocking
weekdays and their state (`ABSENT`, `PENDING` or `TOT_FILE_MISMATCH`), and goes out on the alarm
line like any exposure class (§ 4.6). An operator resolves the weekday (re-runs ingest for a lost
file, a late detail lands, or a mismatch is corrected and re-checked); the credit is never matched
around it. This is the "reported against the credit" exception of the paragraph above.

**A late mismatch unwinds what the batch stamped.** If an advice file arrives after a batch went
`COMPLETE_WITHOUT_ADVICE` and fails its `Tot_File` check, the batch moves to `TOT_FILE_MISMATCH`
and its stamps are released in the same transaction: `GREENDOT_BATCH` and `DEPOSIT_IDS` are
deleted from the credit that claimed it, which returns that credit to the queue. The deposit
pointers stay, because Green Dot still named those deposits; only the payment claim is withdrawn.

The account list is the three of § 1, for history. Live, only the FBO receives Green Dot; the
other two are listed so the backfill finds the 31 credits of 2023-05 → 2024-08, and a settlement
credit on any other account is not a candidate.

**Recognising a Green Dot credit** narrows the candidate set before the amount test, which matters
because the FBO account carries 2,945 credits totalling $760M. Two rules, one per feed era:

- **New FIB feed** (`raw.source = 'fib_online_banking'`, #4637): the row carries
  `methodData.method = 'ACH'` with the originator's `companyId` and `companyName` from the ACH
  detail. Match on Green Dot's **ACH Company ID**, pinned as a constant from the first captured
  Green Dot credit. A third party can write any description on its own ACH; it cannot originate
  under Green Dot's company id.
- **Every other row with no ACH method data** (`raw.source = 'metcap'`, `'manual'`, no `raw`, or a
  re-keyed historical row that kept no `methodData`): match the description
  `GREEN DOT BANK … GDB FR ACH CFX LABS INC.`. The rule keys on the absence of ACH method data, not
  on `raw.source`, so the 3 hand-entered credits of 2026-10-14 → 16 and any row #4637's re-key
  rewrites are recognised. Only a row that carries ACH method data must name Green Dot's company id.
  The one historical `GREEN DOT BANK - PAYMENT` row (`banktx_bBTIvdl1ZmMrXXacjh2CI`, § 1) is
  accepted by id in the backfill only.

**A credit that matches no batch is queued with suggestions, never stamped.** § 7.1 establishes that
Green Dot also settles individual deposits outside the daily transmission: once the backfill has
run, 12 of the 16 batch-unmatched credits pair to `DEPOSITED` cash deposits that appear in no
settlement file. But
that pairing is by amount alone — the kind of heuristic match § 4.3 refuses. So the credit is
classed `CREDIT_PER_DEPOSIT` and queued with **suggested deposits**: unsettled `DEPOSITED` cash
deposits whose gross `sourceAmount` equals the credit, and pairs of them that sum to it, created
in the **30 days** before the credit and not already claimed by another credit. The window is wide
on purpose: Green Dot paid the 2026-07-22 → 08-03 register cluster in one sweep on 2026-08-10, up
to 19 days late, which a 1-4 day window would have reported as unpaid. An operator picks one; only
then is `DEPOSIT_IDS` stamped, with no `GREENDOT_BATCH` (there is no batch to name). The
suggestion covers 11 single and 1 paired historical case; the 4 credits ($3,812.92) with no
suggestion are `CREDIT_UNBATCHED` (§ 7.1).

**A suggestion is not a claim.** Two credits of the same amount can suggest the same deposit, and
both suggestions stand until an operator picks one. Picking stamps that deposit into one credit's
`DEPOSIT_IDS`, which removes it from every other credit's suggestions, because "already claimed"
reads `DEPOSIT_IDS` and nothing else. Coverage counts the same way: `MINTED_NOT_SETTLED` and its
alarm treat a deposit as paid only once it is in some credit's `DEPOSIT_IDS`, never because a
suggestion names it.

**Allow a same-day credit when the file is the day's last.** The `[1,4]` window holds for 380
credits; the exception is the MetCap cutover, where the 2026-10-09 batch's credit posted on
2026-10-09 itself. That one sits in `BATCH_UNCREDITED` and `CREDIT_UNBATCHED` at once, a pair an
operator links by hand. Widening the window to `[0,4]` for every batch would buy one match and risk
small round totals pairing with the wrong day; leave the window alone.

**Matching runs from both ends, because the credit lands 1-4 days after the file.** Either side can
arrive first, so neither may assume the other is there:

| Trigger                               | Runs                                                                                                                                                                                                                                                                           |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| a settlement file is ingested (§ 4.2) | entry ladder (§ 4.3), then credit-to-batch for any Green Dot credit already posted in `[file_date+1, file_date+4]`                                                                                                                                                             |
| a Green Dot credit is written         | the `GREENDOT_SETTLEMENT` router arm: credit-to-batch, else queue with suggestions                                                                                                                                                                                             |
| daily                                 | promotes each `PENDING` batch whose detail is in and whose file date is a weekday or more past to `COMPLETE_WITHOUT_ADVICE` (§ 4.2) and runs the entry ladder (§ 4.3) on it, then retries credits still unmatched, and emits one line per exposure class for the alarm (§ 4.6) |

The second trigger is `bankTransactionReconciliationWorkflow`, which every feed already starts for
each row it writes (`startBankTransactionReconciliationWorkflow`). **#4637's FIB sync gains that
call**, as the Victor and RouteFusion syncs have, so a credit is matched as it lands and the daily
pass is a backstop. Both paths stamp through the same function and under the one-to-one constraint,
so whichever runs second finds the work done.

### 4.5 What gets stamped

```
data.reconciliation.GREENDOT_BATCH = "gdsb_..."        new scalar key
data.reconciliation.DEPOSIT_IDS    = ["dp_...", ...]   existing array key
```

Reusing `DEPOSIT_IDS` rather than a cash-specific array is the highest-leverage decision here:

- `BankTransactionUnreconcileService.unreconcile` already strips a deposit id from that array and
  drops the key when it empties.
- `idx_bank_tx_recon_deposit_ids` (GIN) already exists.
- `bankTransactionReconciliationJoinSql("DEPOSIT", …)` already joins through it, so cash deposits
  gain the "which credit funded this?" link every other rail has, with no view DDL changes. It does
  change what `deposit_transaction_view` projects for a cash deposit: `bank_transaction_id`,
  `bank_id` and `bank_name` go from null to the Green Dot credit's. Those columns already carry the
  credit for every other rail, so a reader that filters on them now sees cash deposits too; check
  the admin bank-transaction filters against that before step 2 stamps history.

**`GREENDOT_BATCH` needs code in four places, not a literal.** #4713 adds the literal in
[`bank_transaction.ts`](../../packages/db/src/drizzle/schema/bank_transaction.ts), its partial
btree index on `data -> 'reconciliation' ? 'GREENDOT_BATCH'`, and the scalar-only key in
[`reconciliation.ts`](../../packages/db/src/drizzle/reconciliation.ts) (keep that file's
redundant-looking `? '<KEY>'` guards: they keep the planner off a ~40s seq-scan join). The write
path is in `bank-transaction-services` and has to learn the key too:

| Site                                                           | Change                                                                                                                                                                                                                                                  |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BANK_CREDIT_RECONCILIATION_TYPES`                             | add `GREENDOT_BATCH`                                                                                                                                                                                                                                    |
| `CreditReconciliationInput` / `getCreditReconciliationUpdates` | add `greendotBatchId`, mapped to `GREENDOT_BATCH`; without it the key is silently dropped. Both `greendotBatchId` and `depositIds` accept `null`, which removes the key through the existing `ReconciliationPatch` (today only `operatingEntryId` does) |
| `ReconciliationCasUpdateArgs` (`update`)                       | accept `type: "CREDIT"` with `expectedReconciliation`, so a release or an unwind removes both keys only from the credit it read, and a concurrent stamp fails with `BANK_TRANSACTION_RECONCILIATION_CHANGED` instead of being overwritten               |
| `stripRailIdFromReconciliation` (`internal.ts`)                | unchanged: a cancel strips `DEPOSIT_IDS` and leaves `GREENDOT_BATCH` (see below)                                                                                                                                                                        |
| `ReconciliationRailKey` (`internal.ts`)                        | unchanged: `GREENDOT_BATCH` is not a rail id and is never stripped by id                                                                                                                                                                                |

**Cancelling deposits never releases the batch.** `unreconcile` strips a cancelled deposit from
`DEPOSIT_IDS` and drops the array when it empties, and leaves `GREENDOT_BATCH` alone. The credit is
Green Dot's payment for the batch, and a cancel on CFX's side does not change what Green Dot paid
for: the credit stays `RECONCILED` against the batch, the same `GREENDOT_BATCH`-only state below,
and each cancelled deposit is `SETTLED_BUT_CANCELLED` (§ 4.6) for an operator. Releasing the batch
would also be undone at once, because the deterministic matcher would pair the same credit and batch
again on its next pass. `stripRailIdFromReconciliation` therefore needs no change for this key.
_(My call: a cancel is a CFX-side event; a batch is freed only by the two releases below.)_

**A credit can carry `GREENDOT_BATCH` alone, and an operator releases it.** When none of a batch's
entries resolve to a deposit (all `UNMATCHED_NO_DEPOSIT`), the credit is still paid for that batch,
so it is stamped with `GREENDOT_BATCH` and no `DEPOSIT_IDS`. `unreconcile` never reaches such a
credit, because it finds credits through a deposit, and that is correct: no deposit is on it. The
only way such a claim is wrong is a wrong match, so release is an explicit admin action on the credit
that deletes `GREENDOT_BATCH` and `DEPOSIT_IDS` together, clears nothing on the deposits (their
pointer names the batch, not the credit), and returns both the credit and the batch to the matcher.

**A batch is released two ways, and no others.** By an operator, for a wrong match (above). Or
automatically, when a late advice fails its `Tot_File` check (§ 4.4): Green Dot's own files then
disagree, the batch becomes `TOT_FILE_MISMATCH`, and a mismatched batch can claim nothing. A deposit
cancel, a re-uploaded file (§ 4.2 freezes a credited batch) or any other ingest never releases one.
Both releases remove `GREENDOT_BATCH` and `DEPOSIT_IDS` through the credit compare-and-set above.

**A released pair is re-matched unless the operator stamps the right batch.** The matcher is
deterministic, so releasing a wrong match and leaving the credit unstamped lets the next run pair
it again. The release action stamps the right batch in the same step, which takes the credit out
of the matcher's population. Recording rejected pairs waits for that action to exist and for the
re-match to happen in practice; the backfill left no ambiguous credit to release.

**One credit per batch, enforced by the database.** The one-to-one constraint of § 4.4 is not left to
the matcher: a unique partial index on `(data -> 'reconciliation' ->> 'GREENDOT_BATCH')` makes a
second credit claiming the same batch fail its write, so two concurrent runs cannot both stamp one
batch. That is a follow-up migration to #4713, whose index on the key is not unique.

Recomputing `DEPOSIT_IDS` must exclude `CANCELLED` deposits. `BankTransactionService.update`
merges jsonb with `||` and replaces an array key wholesale, so a retry would re-add exactly what
`unreconcile` removed. `AchDebitDepositBatchReconciliationService` filters for this reason.

**The two axes are decoupled.** Whether a credit is identified (`GREENDOT_BATCH`) and whether its
entries resolved (`DEPOSIT_IDS`) are separate questions:

| Axis             | Stamped when                  | On failure          |
| ---------------- | ----------------------------- | ------------------- |
| credit to batch  | amount ties, candidate unique | queue the credit    |
| entry to deposit | per entry, via § 4.3          | queue the **entry** |

All-or-nothing per batch, which is what `AchCreditWithdrawalBatchReconciliationService` does,
would leave a $130,989.94 batch (the largest) unreconciled over one unresolvable entry. Green Dot's
total and the credit are both immutable once matched: the live path reads main files only, and the
one source that ever added rows to an existing transmission, the 2023 reruns, is merged by the
backfill before any credit is matched (§ 4.2). So there is no risk of the wedging that forced the
ACH service to compare against a filed sum rather than a live one.

**A credit stamped before all its entries resolve is re-entered, not skipped.** The planner returns
`ALREADY_RECONCILED` for any row whose `data.reconciliation` is non-empty, so a credit carrying
`GREENDOT_BATCH` with a short `DEPOSIT_IDS` would never be revisited through the router. The
Green Dot path does not go through that guard for later entries: when the ladder resolves an entry
whose batch is already claimed, it appends the deposit to that credit's `DEPOSIT_IDS` directly,
keyed by the batch (`? 'GREENDOT_BATCH'` and `->> 'GREENDOT_BATCH' = <batch>`), through the same
stamping function. The router guard stays as it is for every other rail.

### 4.6 Classification

The exception taxonomy, each class observed or derivable from the data above:

| Class                   | Meaning                                                                                                                                     | Observed today         |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `SETTLED_NOT_DEPOSITED` | Green Dot settled it, the deposit is still `PROCESSING`                                                                                     | 0 (37, all worked)     |
| `MINTED_NOT_SETTLED`    | deposit minted, older than 7 days, in no settlement file and in no credit's `DEPOSIT_IDS`                                                   | 13 (all credit-paired) |
| `CREDIT_PER_DEPOSIT`    | credit matches no batch but has suggested deposits (§ 4.4)                                                                                  | 12                     |
| `AMOUNT_MISMATCH`       | settled amount differs from the quote's gross `sourceAmount`                                                                                | 6, none since 2025-05  |
| `BATCH_UNCREDITED`      | non-zero batch, no credit after the window, and no reversal debit                                                                           | 2 (+1 in flight)       |
| `CREDIT_UNBATCHED`      | Green Dot credit matching neither a batch nor a deposit                                                                                     | 4                      |
| `SETTLEMENT_REVERSED`   | a Green Dot debit (§ 4.4 recognition) equal to one unreversed batch's total, within 30 days of its file date                                | 1                      |
| `UNMATCHED_NO_DEPOSIT`  | Green Dot settled cash with no CFX deposit behind it                                                                                        | 8 (7 quote, 1 deposit) |
| `SETTLED_BUT_CANCELLED` | deposit is `CANCELLED` yet Green Dot settled it                                                                                             | 0                      |
| `CREDIT_AMBIGUOUS`      | more than one batch matches a credit on amount and window                                                                                   | 7                      |
| `TOT_FILE_MISMATCH`     | a detail file whose sum does not tie to its advice `Tot_File`; never matched against                                                        | 0 (323 of 323 tie)     |
| `ADVICE_MISSING`        | a live batch matched without its advice, one weekday past its file date (§ 4.2)                                                             | 0 since 2026-04-07     |
| `CREDIT_WINDOW_BLOCKED` | a credit whose window still has a weekday with no batch, or a `PENDING` or `TOT_FILE_MISMATCH` batch, four weekdays after it posted (§ 4.4) | 0                      |

**The classes are disjoint by construction.** A deposit is `MINTED_NOT_SETTLED` only while it is in
neither a file nor a credit's `DEPOSIT_IDS`, so a confirmed per-deposit settlement leaves it. A
zero-total batch (a filler day) is in no class. A batch a debit reverses is `SETTLEMENT_REVERSED`
and never `BATCH_UNCREDITED`: the 2025-11-17 batch ($330.28) was never credited and has its reversal
debit, so it is counted once, under the reversal, which is why `BATCH_UNCREDITED` reads 2, not 3.

**A reversal is matched as strictly as a credit.** The debit must be recognised as Green Dot's the
same way a credit is (§ 4.4: company id on the new feed, `GREEN DOT BANK … GDB FR ACH` otherwise;
the one observed reversal carries exactly that description, posted 11 days after its batch), its
amount must equal the total of exactly one unreversed batch within 30 days before it, and one debit
reverses at most one batch. The batch may be credited or not: Green Dot can claw back a batch it has
already paid, and that is still one reversal of one batch. Two candidate batches, or a debit outside
the window, is queued as an unexplained Green Dot debit, never classed `SETTLEMENT_REVERSED` on a
guess, so an unpaid batch is never hidden from `BATCH_UNCREDITED` by a coincidental debit.

Every class carries a measured population, so the view can be validated against these numbers on
first run: a class returning wildly more or fewer rows than the column above means the
implementation is wrong, not that the world changed. The column is what the backfill script reports
(§ 4.2), reruns imported, as of 2026-10-17. Before the rerun import, three classes read differently
because 18 of the 2023 transmissions are still empty: `CREDIT_PER_DEPOSIT` 27, `CREDIT_UNBATCHED` 7,
`MINTED_NOT_SETTLED` 32. Checking against the wrong column is the easiest way to misread a correct
run as a broken one.

`MINTED_NOT_SETTLED` is the one that quantifies credit exposure to Green Dot, and the reason this
project is worth doing beyond queue hygiene. It carries the 7-day age floor of § 7.3 for a
concrete reason: without it the class also holds the 31 deposits that are merely in flight on any
given day, and the alarm fires on ordinary traffic every morning. The alarm counts aged deposits
in no credit's `DEPOSIT_IDS` (§ 4.4: a suggestion is not a claim). After the backfill that is the
13 aged deposits (32 before the rerun import), every one of which has a credit suggesting it, so
the alarm's first real job is to go to zero as an operator confirms those 12 credits (§ 7.5).

`SETTLED_NOT_DEPOSITED` is the one to work **per deposit rather than as a batch**: membership means
Green Dot paid, not that CFX still owes. An on-chain mint check comes first; § 2.4 records the rule
the first 37 were worked by.

**Classes are computed, never stored.** One view derives all thirteen from the tables, the deposits and
the credits as they are now. Entries store only the ladder's result (§ 4.1). So aging needs no
writer: `MINTED_NOT_SETTLED`'s 7-day floor and `BATCH_UNCREDITED`'s window are predicates on
`now()`, and a class cannot go stale because nothing wrote it. `SETTLEMENT_REVERSED` is a Green Dot
debit on the FBO matched to one unreversed batch by the rule below; it is queued, never stamped.

**It must not be named `*_transaction_view` or `*_bank_account_view`** — two contract tests
discover view arms by those suffixes and fail on any new match, which is why
`bank_transaction_reconcile_queue_view` was renamed from `unreconciled_bank_transaction_view`.

**The alarm covers every form of money Green Dot owes.** `MINTED_NOT_SETTLED` is cash CFX minted
that no file names. A file Green Dot never paid (`BATCH_UNCREDITED`) and one it paid and took back
(`SETTLEMENT_REVERSED`) are the same exposure from the other side, and their deposits do have batch
pointers, so `MINTED_NOT_SETTLED` alone would miss them. So the daily pass logs one structured line
per exposure class, each with its count and value: `MINTED_NOT_SETTLED`, `BATCH_UNCREDITED`,
`SETTLEMENT_REVERSED`, `TOT_FILE_MISMATCH`, `ADVICE_MISSING` and `CREDIT_WINDOW_BLOCKED`. Each has a CloudWatch metric filter and an alarm on
the shared ops SNS topic, which reaches Slack through the existing Chatbot with no new notification
code. A `BATCH_UNCREDITED` batch is counted only once its `[1,4]` window has passed, so an
in-flight batch never fires it.

### 4.7 Where the code lives

| Concern                                                                           | Home                                                   |
| --------------------------------------------------------------------------------- | ------------------------------------------------------ |
| File event publish (built)                                                        | `greendot-settlement-services`, via `s3-event-handler` |
| S3 read, CSV parse, batch/entry upsert, match ladder, ingest workflow, daily pass | `greendot-settlement-services`                         |
| The event arm that starts the ingest workflow                                     | `backoffice-api` `reconciliationHandler`               |
| Stamping `bank_transaction`                                                       | `packages/bank-transaction-reconciliation-services`    |
| The deposit pointer column and its admin read                                     | `db`, `deposit-services`, `admin-dashboard`            |

The stamping half joins the existing plan/act router as a `GREENDOT_SETTLEMENT` arm, tested
**after the `ALREADY_RECONCILED` and ACH-return guards and before the `if (!method)` guard** in
`planBankTransactionReconciliation`
([bankTransactionReconciliationActivities.ts](../../packages/bank-transaction-reconciliation-services/src/bankTransactionReconciliationActivities.ts)).
Historical rows carry no method and would otherwise short-circuit there with `NOT_APPLICABLE`;
new-feed rows carry `method = 'ACH'` and would otherwise fall into the ACH credit arm. The
exhaustiveness check over `method` stays intact.

Registering the ingest workflow follows the temporal-worker checklist (worker import,
`mergeActivities`, the DI bag, the `workflows.ts` re-export). The daily pass is the one schedule,
declared beside the others so `ensureSchedules` creates it on deploy.

New infrastructure: a `GREENDOT_S3_BUCKET` env key and an S3 read grant on the temporal-worker
Lambda (`s3-event-handler` already has both), and the `backoffice-api` reconciliation function needs
the Temporal client it already holds for `BankInternalTransferEventConsumer`.

## 5. Order of work

History first, then the live path. The first three steps reconcile three and a half years once and
hand a person the list of what is left; the live path is then switched on over a clean history.

| #   | Change                                                                                                                       | Shippable alone                                               |
| --- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| 0   | File event published from S3 (#4446, #4447)                                                                                  | **Built**                                                     |
| 1   | **Create the tables**: the batch and entry tables, the deposit pointer columns, the `GREENDOT_BATCH` key and its index       | Yes                                                           |
| 2   | **Backfill, reconcile the trivial, list the leftovers**: the throwaway script of § 4.2, run once, then deleted               | Yes                                                           |
| 3   | **Investigate the leftovers** by hand, from the script's list                                                                | —                                                             |
| 4   | Live path: narrow `receive`, the ingest workflow and its `backoffice-api` arm, the router arm, the daily pass; #4637's start | Ingest yes; live credits need #4637                           |
| 5   | Admin: transmission on the deposit detail, the exception view and queue with suggestion pick, the `MINTED_NOT_SETTLED` alarm | Yes                                                           |
| 6   | One-off sweep of the historical fee residue in `DEPOSIT_PENDING_WALLET` (§ 8)                                                | Yes; **before** `PENDING_WALLET_RETIREMENT` deletes the sweep |

Step 1 is pure addition. Step 2 writes only its own tables, the deposit pointers and reconciliation
markers on the historical credits, and stamps only exact matches. Steps 2 and 3 need nothing from
the live credit feed, so the historical reconciliation does not wait on #4637. The match ladder
and stamping function are written once, in step 2's PR, and step 4 reuses them.

**Deploy order is not row order.** Step 4's live _ingest_ (the consumer of the file event) is
switched on before step 2's script runs, and the script is run twice (§ 4.2), so no file landing
during the rollout is missed. Step 4's router arm and daily pass can follow at any point after.

Step 6 is independent of steps 1-5 and is not reconciler behaviour. Its only ordering constraint is
the deletion of `sweepDepositFeesFromPendingWallet` in
[PENDING_WALLET_RETIREMENT.md](PENDING_WALLET_RETIREMENT.md): run it first, or port the transfer
into the script.

## 6. Non-goals

- **Auto-releasing a stranded deposit that Green Dot settled.** `CashDepositReconciliationService.release`
  stays the manual lever. Settlement is evidence for an operator, not a trigger.
- **A new `BankTransactionRawSource`.** The credit is the FIB feed's row; the reconciler writes none.
- **Requiring the advice-of-credit file.** It is absent for 586 of 909 dates. The live path waits
  for it by one weekday and then proceeds without it, alarmed (§ 4.2); it never blocks on it.
- **Fees, in any form.** The credits are gross, there is no Green Dot fee in this data, and since
  #4695 a deposit's own fee is minted straight to `FEE`. Reconciliation never sweeps; § 8's one-off
  historical sweep is an operator step, not reconciler behaviour.
- **Minting the 95 era-one fees** (§ 8, $324.49). Those fee tokens never existed; minting them is a
  separate decision with no owner here.
- **Exposing the transmission outside CFX.** No public API field, no customer or bank dashboard, no
  webhook.
- **Replacing the barcode lifecycle work in [CASH_DEPOSIT_BARCODE_STATE.md](CASH_DEPOSIT_BARCODE_STATE.md).**
  That covers authorisation, this covers settlement. They compose and neither blocks the other.

## 7. Resolved questions

Every question this document opened has since been answered from the data. They are kept here
because the answers change the design.

### 7.1 The unmatched credits are per-deposit settlements

**Answered: they settle the deposits that never appeared in a transmission file.** The unmatched
credits and the "minted but unsettled" deposits are the same phenomenon seen from two sides.

Two populations, because the 2023 rerun import (§ 4.2) is what decides whether 18 of the 2023
credits have a batch. **After the backfill**, with the reruns imported, 16 credits match no batch.
Pairing each against a `DEPOSITED` cash deposit that appears in no settlement file, by amount, in
the 30 days before the credit:

| Outcome                                                             | Credits | Value     |
| ------------------------------------------------------------------- | ------- | --------- |
| paired 1:1 with an unsettled deposit                                | 11      | $3,185.99 |
| paired as a sum of two (55.29 = 35.29 + 20)                         | 1       | $55.29    |
| the MetCap cutover credit, which matches its batch same-day (§ 4.4) | 1       | $2,940.90 |
| still unexplained (20.00, 191.46, 660.56)                           | 3       | $872.02   |

**On main files alone**, before the rerun import, the same pairing runs over 34 credits: 24 pair
1:1 and 3 as a sum of two, 15 of them 2023 credits whose transmissions shipped empty, and 3 more
2023-05 credits ($100) point at test rows only the reruns list. That is the per-deposit arm
covering exactly the gap the reruns fill, and why § 4.2 merges them before reconciling anything rather
than leaving 18 credits to be confirmed by hand.

The first draft counted 23 unmatched and 19 paired. Its inputs have grown, not only its window: on
2026-10-17 there are 376 Green Dot credits and 364 real batches (with the reruns) dated on or before
2026-09-08, where the first draft counted 344 and 325 over the same dates. Rows arrived for that
period after it was measured, and § 2.4's 37 stranded deposits, once worked, became `DEPOSITED`
deposits with credits to pair against.

The 2026-08-10 cluster is the clearest case: ten credits that day, one of which is the 2026-08-07
transmission ($37,555.93) and nine of which are round register amounts (500, 500, 500, 400, 320,
250, 100, 50, 50) sharing a single ACH trace. They pay the register cluster of 2026-07-22 →
2026-08-03 ($2,670 on both sides), up to 19 days after the deposits — which is why the suggestion
window in § 4.4 is 30 days rather than the batch window's four.

The 3 unexplained credits ($872.02, 2025-10 → 2025-11), the one batch with no credit at all
(2025-10-14, $211.46) and the one reversed batch (2025-11-17, $330.28, never credited and debited
back on 2025-11-28) are the only anomalies left in three and a half years. They are worth one
question to Green Dot.

**Design consequence:** a credit that matches no batch is tested against unsettled individual
deposits, but only to **suggest** them to an operator (§ 4.4). The pairing is by amount alone, which
is the kind of match § 4.3 refuses to make automatically, so `DEPOSIT_IDS` is stamped only once a
person picks one.

### 7.2 The description sequence numbers do not identify the transmission

**Answered: no.** Both numbers in `GREEN DOT BANK <a> - GDB FR ACH CFX LABS INC. <b>` are Green
Dot's own ACH counters. They rise monotonically with time, but across the 315 credits confidently
paired on 2026-09-08 there were 315 distinct `seq_a − batch_ordinal` offsets, so neither is derivable from
`TransmissionID`. They are also **not unique**: all nine of the 2026-08-10 adjustment credits share
`seq_a = 71811` and `seq_b = 3539`.

So amount plus a `[1,4]` day window, plus the one-to-one constraint, remains the batch key. The
sequence numbers are worth storing for audit and nothing else.

### 7.3 The alarm threshold is 7 calendar days

**Answered from the lag distribution.** Every settlement row that has ever been issued appeared in
a file within 4 calendar days of the register transaction (1 day ×10,634, 2 ×2,188, 3 ×2,273,
4 ×223; nothing beyond). Seven calendar days therefore clears the observed maximum plus a long
weekend, and is the right point for a deposit to be called anomalous rather than in flight.

That threshold matters more than it looks. Of the 63 deposits currently in no settlement file, **31
were created in the last 7 days** and are simply in flight; an alarm without an age floor would
fire on ordinary traffic every single day.

### 7.4 Retention needs no policy

**Answered by arithmetic.** `raw` is roughly 400 bytes per entry at about 1,000 entries a month
(934 in the month to 2026-10-16), so under 10 MB a year. Keep everything; revisit if the table passes 10M rows, which at current volume
is some four centuries away.

### 7.5 The real exposure is zero

**Answered, and both earlier figures were wrong.** The first draft's $6,281.09 came from subtracting
two totals (`DEPOSITED` deposits minus batch sums), which silently netted three unrelated
populations against each other and mixed the fee-netted rows of § 4.3 into the arithmetic. Its
deposit-by-deposit replacement, ~$1,761, paired deposits to credits only inside a two-week window,
and so missed the 2026-08-10 sweep that paid the July register cluster up to 19 days late. Computed
directly on 2026-10-17, with the 30-day suggestion window of § 4.4:

| Population                                                       | Count | Value       |
| ---------------------------------------------------------------- | ----- | ----------- |
| `DEPOSITED` cash deposits absent from every main settlement file | 63    | $14,286.97  |
| created within the last 7 days, so still in flight               | 31    | ($9,962.69) |
| older, and paid by a per-deposit credit (§ 7.1)                  | 32    | ($4,324.28) |
| **genuinely unaccounted for, over three and a half years**       | **0** | **$0**      |

After the rerun import 19 of the 32 aged deposits gain a batch, leaving 44 absent from every file
(31 in flight, 13 aged and credit-paired); the answer is still zero.

"Paid" here is the measurement, an amount-and-window pairing to a real Green Dot credit, not yet a
reconciliation: by § 4.4 the system counts those 13 as covered only once an operator confirms each
credit's suggestion, so until then they sit in `MINTED_NOT_SETTLED`.

So every cash deposit CFX minted more than a week ago has a Green Dot credit behind it — in a batch,
or individually. What remains is the other direction: 3 credits ($872.02) and 1 batch ($211.46)
from 2025-10 → 2025-11 that Green Dot paid or listed with nothing on the other side (§ 7.1). That
is money CFX received, not money it is owed.

### 7.6 The net-amount era ended in a nameable commit; it began in fee config

**Answered, in two halves that have different kinds of cause.**

**The end is a code change.** [`7b2af67e061`](https://github.com/CFXLabsInc/cfx-platform/commit/7b2af67e061),
2025-10-25, "feat: unxstateify cash deposits (#1851)". Before it, a cash deposit was created by the
legacy XState workflow through `LegacyDepositRepository` with `amount: quote.amountOut` — and
`amountOut` was explicitly net (`depositIn.minus(sum(fees))`). That commit deleted the legacy
repository and routed creation through `DepositAdminService.create`, which writes
`depositQuote.quote.sourceAmount` — gross. One commit, net to gross, and it matches the measured
last-net month exactly.

**The beginning is not a code change.** The cash path had written `quote.amountOut` continuously
since 2023-09. Rows before roughly 2024-10 show no delta only because the CFX fee on
`deposit.us_cash.v1` was **zero**: the product seed carried `"cfxFixedFee": 0` as late as
2024-10-15, and the first nonzero cash fee anywhere in git is a `FIXED` `"4.50"` — one of the
measured deltas — in 2025-01. Production fee configuration has never lived in this repo
(`packages/db/seed/` is local-only per [rules/database.md](../../rules/database.md), and no
migration has ever set a cash fee), so **git cannot date the production change**. The authoritative
record is `b2b.product_fee_rule_version` and the archived legacy `product_fee_config` rows in prod.
State it that way rather than attributing it to a commit.

Two things this rules out, both load-bearing for § 4.3:

- **There is no second writer.** `deposit.amount` is write-once: the only `UPDATE` on the table
  builds its set clause from a param type that does not admit `amount`, and no migration rewrites
  it.
- **`sourceAmount` is gross in every era, including the legacy rows** — confirmed against prod, not
  inferred: sampled era-one quotes read `sourceAmount 34.99` against a stored `deposit.amount` of
  `30.49`, and the settlement file's register amount agrees with the former. A migration once
  rewrote the legacy `{amountIn, amountOut}` shape into `sourceAmount`, but it has since been
  squashed into `20230620062300_initial.sql` and **cannot be cited from the tree**; the prod data is
  the evidence. The current mapper has no `amountIn` branch left, so it depends on that backfill
  having run.

One correction this forces on § 4.3: `sourceAmount` is the **authorised** amount, not the settled
one. `AuthCommit` re-quotes but never writes the settled figure back to either the quote or the
deposit, so both keep the `Auth` amount. Comparing settlement against `sourceAmount` is still
right, and `AMOUNT_MISMATCH` still has a real job — it catches a register that settled a different
amount than it authorised.

An incidental repo fact found on the way: **`origin/main` is re-rooted at `8a30342b4d7`
(2026-04-20) and has no parent.** Everything older is in the object store but is not an ancestor of
`main`, so `git log origin/main` silently shows nothing before that date. Any future archaeology on
this codebase has to reach for the stale local branches instead.

## 8. The fee residue this uncovered

Chasing § 7.6 surfaced a larger, adjacent problem that settlement reconciliation does not cause and
does not fix, but is the only thing that made it visible. Recording it here because the two share a
root: the register amount is the one figure CFX never held authoritatively.

**5,523 cash deposits are missing their CFX fee, worth $31,295.42** — the same on 2026-10-17 as on
2026-09-08, so nothing has swept any of it since. The cause is three code eras, and — this is the
load-bearing part — **the middle era needs the opposite remedy to the first**:

| Era                      | Deposits | Fee value  | What the mint did                                    | Remedy       |
| ------------------------ | -------- | ---------- | ---------------------------------------------------- | ------------ |
| → 2025-10-25             | 95       | $324.49    | minted **net only**; fee never existed               | out of scope |
| → 2026-04-16             | 4,589    | $26,289.50 | minted gross; sweep did not exist yet                | **sweep**    |
| → 2026-04-28 (inclusive) | 839      | $4,681.43  | same, deploy lag behind the commit                   | **sweep**    |
| 2026-04-29 → 2026-10-16  | 0        | —          | minted gross; the workflow swept                     | none         |
| 2026-10-16 →             | 0        | —          | **the mint pays `FEE` directly** (#4695); no residue | none         |

The third era's last day is the deploy day itself: 62 of its 839 deposits were created on
2026-04-28, before the sweep reached the worker.

The era counts above split at the commit date. The backfill splits at the deploy: `7b2af67e061`
first reached prod at 2025-10-27T15:42:00Z, and deposits created before that were still minted net,
so they belong to era one.

`7b2af67e061` (2025-10-25) switched the mint from net to gross-into-`DEPOSIT_PENDING_WALLET`;
`260312ac20b` (2026-04-16) added `sweepDepositFeesFromPendingWallet` and the `fee_sweep_tx_id`
column. Between them the residue accumulated with nothing to move it. Since #4695 a new deposit's
fee never enters the pending wallet at all: one instruction mints the net to the customer and the
fee to `FEE`, and records that signature as `fee_sweep_tx_id`. So **this is a closed backfill, not a
live defect**, and the only thing to sweep is what eras two and three left behind: **5,428 deposits,
$30,970.93**, sitting in `DEPOSIT_PENDING_WALLET` today.

**Do not key a remediation on the symptom.** `fee_sweep_tx_id IS NULL` is true for eras one to
three and for every zero-fee deposit. The eras must be separated, and a zero fee is legitimately
unswept forever — `sweepDepositFeesFromPendingWallet` returns early on `feeAmount.lte(0)` and
writes nothing.

**Why a sweep is safe to run in bulk:** it moves tokens that must already exist, so if they do not
the transaction simply fails and no money is created. It also needs no new financial logic:
`sweepDepositFeesFromPendingWallet` is already idempotent on `feeSweepTxId`, already computes the
fee, and already persists its own result, so a backfill is a loop over tested code. Since #4695 it
also returns the recorded signature for a deposit whose mint paid its own fee, so a deposit from
the new era slipping into the selection is a no-op.

**Done and removed.** The canary and the full run both ran on 2026-10-26 (`SWEPT 4789,
ALREADY_SWEPT 646, WALLET_BALANCE 1`). ENG-3865 then deleted the backfill, its runbook, the shared
transfer and `sweepDepositFeesFromPendingWallet`; the one refused deposit (a 3.09 MOVEUSD fee) is
left to the pending-wallet drain. The rest of this section records the design as it ran.

The sweep was the `cash-deposit-fee-sweep` backfill (`cashDepositFeeSweepPages.ts`, registered in
`packages/background-worker/src/backfills/`), run through the generic `backfillWorkflow`
on the `cfx-backfill` queue, with a one-deposit `cash-deposit-fee-sweep-canary`
beside it and a `verify-cash-deposit-fee-sweeps.ts` companion that checks each sweep against the
chain. It selects only deposits on a settlement batch a bank transaction has reconciled as
`GREENDOT_BATCH`. It was a one-off operator run rather than dashboard or reconciler behaviour.
**The reconciler never mints or sweeps.**

Three constraints on the run:

- **Select by era, not by symptom.** Eras two and three only: `created_at` from
  2025-10-27T15:42:00Z through the end of 2026-04-28 (a `< '2026-04-29T00:00:00Z'` bound, or the 62
  deploy-day deposits are left behind) and a positive fee. A deposit with `fee_sweep_tx_id` set stays
  selected, and the sweep sends nothing for one whose recorded signature is confirmed. Era-one
  deposits would fail harmlessly, but a selector that admits them is a selector that has not
  separated the eras.
- **The backfill guards the wallet balance.** Before the first page of a real run, it reads
  `DEPOSIT_PENDING_WALLET`'s MOVEUSD balance against the selected deposits' unrecorded fees, and
  **stops the run** (`FEE_SWEEP_WALLET_SHORT`) if the balance is short: a shortfall means some
  residue was already moved by hand, and the run would otherwise spend tokens owed to someone. Since
  #4695 mints are net, so an in-flight deposit holds nothing in the pending wallet. A dry run reports
  the same check as a `WALLET_BALANCE` row. The guard is in the code, not a runbook step, so no run
  can skip it.
- **Run it before the sweep code is deleted.** [PENDING_WALLET_RETIREMENT.md](PENDING_WALLET_RETIREMENT.md)
  deletes `sweepDepositFeesFromPendingWallet` and this backfill. The backfill is § 5 step 6 and must
  precede that deletion.

The 95 era-one deposits ($324.49) are out of scope: their fee tokens never existed, so there is
nothing in the pending wallet to sweep, and minting them is a separate decision.

**The signing stays on the worker.** A `no-restricted-imports` rule in
[`bunfig.toml`](../../bunfig.toml) bans naming `BackofficeWalletService` outside `solana-services`,
`temporal-worker` and `*Activities.ts`, because the keys it reads include the mintfx program
upgrade authority. The backfill and the live activity shared one transfer,
`pendingWalletFeeSweep.ts`, which
takes the signer as a `DepositActivityDependencies` field rather than importing it; the worker's
`compose.ts` is the only place that hands it one. Idempotency comes from the recorded signature, below,
so a restart resumes.

**An era-one deposit must never reach a sweep, because it would not fail.** Its fee tokens never
existed, but `DEPOSIT_PENDING_WALLET` is one shared wallet: a transfer of that fee succeeds out of
whatever other deposits' residue sits there, and silently pays one deposit's fee with another's
tokens. The era selector is the only guard, so the backfill asserts it: it stops
(`FEE_SWEEP_BEFORE_ERA`) if any selected deposit was created before 2025-10-27T15:42:00Z. The balance
guard above is the backstop, not the fence.

**A timeout must not re-send.** The transfer carries no on-chain idempotency key, so a send that
timed out confirming but in fact landed, retried, sweeps the fee twice from the shared wallet; that
is how $495.22 was over-swept before. The shared transfer therefore records the signature before it
sends, and settles that one signature before it will send another:

- It signs, writes the signature to `fee_sweep_tx_id`, then sends and confirms at `confirmed`.
- On entry with a signature recorded, it reads that signature's status. Confirmed returns it (this
  includes a mint that paid the fee itself). Failed on chain moved nothing, so a new transfer
  replaces it. Not found, with the deposit row unchanged for 3 minutes, means its blockhash has
  expired and it can never land, so a new one replaces it. Not found and newer throws retryably.
- A send that throws, or does not confirm in time, throws retryably (`FEE_SWEEP_SEND_FAILED`,
  `FEE_SWEEP_UNRESOLVED`): the retry resolves the recorded signature rather than sending blind.

`sweepDepositFeesFromPendingWallet`, the live activity, goes through the same transfer with a 15s
confirmation inside its 30s attempt.

## 9. Restating the 96 net-amount deposits

The era-one rows of § 7.6 hold a net figure in `deposit.amount` where every other cash deposit
holds gross. **96 rows**, all created before 2025-10-25. The correct value is already on each row's
quote, so this restores a derived value rather than inventing one.

This is not § 8's 95. The two are different predicates over the same era: § 9 is every cash
deposit whose `amount` is below its quote's `sourceAmount` (96, any status), § 8 is every
`DEPOSITED` one whose fee is unswept (95). A migration selects by its own predicate,
`deposit.amount < quote.sourceAmount`, never by a count or a date boundary.

`amount` is **not writable through the service.** `DepositAdminService.update`'s param type admits
only `status`, `receiptId`, `receiptType`, `feeSweepTxId`, `feeSweepTxType` and `storeData`, and
the `SET` clause is built from that type, so the column is structurally unreachable. A raw SQL
migration is the only route.

**What reads it, audited before proposing the change.** The blast radius is far smaller than the
column's prominence suggests, because `deposit.amount` materialises at exactly one point —
`mapDepositFromDb`, onto `Deposit["sourceAmount"]` — and almost nothing reads that field:

| Surface                                           | Reads `deposit.amount`?                                                                                   |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `depositTransactionView` and every dashboard list | **No** — reads `product_quote.source_amount`                                                              |
| Dart / FIB reports delivered by SFTP and email    | **No** — no import of deposit services, and cash is excluded twice (by method and by `bankIds: ["dart"]`) |
| Ledger, balances, `bank_account_view`             | **No** — zero references to the deposit table                                                             |
| Reconciliation matching                           | **No** — matches on external id containment, never on amount                                              |
| Webhooks (`deposit.deposit.*`)                    | **No** — those events carry no amount field at all                                                        |
| Customer and bank dashboards                      | Serialised but **never rendered**; detail panels read the quote                                           |
| **Public REST API** `GET /v1/deposit{,/id}`       | **Yes** — this is the only consumer that changes                                                          |

So the single real consequence is that an integrator polling those 96 historical cash deposits
would see `amount` change from net to gross. Nothing that was ever delivered outside the building
is restated: the reports that go to Dart and FIB contain no cash rows, and no webhook ever carried
the figure.

That is a narrow, disclosable change rather than a restatement of published numbers — which is why
the audit was worth doing before writing the migration rather than after.

## 10. The stranded USDC cohort, re-denominated

**Resolved.** #3643 (merged 2026-09-09) re-denominated the 35 deposits as MOVEUSD, and each was
then delivered by hand; none is `PROCESSING` today. Kept because the mechanism below is what made a
redrive unsafe, and it applies to any mint that targets the pending wallet.

§ 2.4's cohort turned out to be a retired product rather than a bug. USDC was a live cash-deposit
target from 2025-10-27 to 2026-06-27 — **610 deposits completed successfully** — and no USDC cash
quote has been created since. The 35 left stuck in `PROCESSING` ($5,953.85) were re-denominated as
MOVEUSD, the currency they settled in.

**Two fields carry a target currency and only one is dispatched on.** This is the detail that makes
the change work:

| Field                                 | Before    | Role                                 |
| ------------------------------------- | --------- | ------------------------------------ |
| `data.targetCurrency`                 | `USDC`    | read once, to build the quote        |
| `quote.targetCurrency`                | `MOVEUSD` | already correct on all 35            |
| `quote.aggregateQuote.targetCurrency` | `USDC`    | **what `cashDepositWorkflow` reads** |

The workflow branches on `aggregateQuote?.targetCurrency ?? quote.targetCurrency`, so dropping
`aggregateQuote` is what moves these onto the MOVEUSD path. Rewriting `data.targetCurrency` alone
would have changed nothing at runtime. Scoped to `PROCESSING`: the 610 `DEPOSITED` rows are
deliberately untouched, because those customers received USDC and rewriting them would falsify a
completed transaction.

**It delivers no funds, and a redrive after it will not either.** All 35 already minted — verified
against `solana_program_event`, whose `ik` is `uuidv5(hex(sha256(idempotencyKey)))`
(`mintfx-client/src/uuid.ts`) — and each carries a `MintEvent` and no other event of any kind. A
USDC-target mint deliberately omits the customer transfer because the swap was meant to deliver, so
the MOVEUSD sits in `DEPOSIT_PENDING_WALLET`. `mintTokensForDeposit` short-circuits on the on-chain
idempotency PDA and never rebuilds its instruction list, so the transfer that lives in that list
**can never fire through the workflow**. Restarting one of these post-migration would skip the swap,
sweep the fee, and mark it `DEPOSITED` with the customer paid nothing. Delivery is a separate,
deliberate transfer.

### What this unblocks, and what it does not

Narrowing the cash `targetCurrency` to `"MOVEUSD"` was blocked on this cohort. The migration
partly lifts that:

- **The workflow's swap branch is gone** (#4709, 2026-10-16), from every deposit rail, not only
  cash.
- **The schema type still cannot be narrowed.** `deposit_quote.data.targetCurrency` remains `USDC`
  on 610 rows, and Drizzle's `$type` on a jsonb column is a cast rather than a validator — so
  narrowing it to `"MOVEUSD"` would make every read of those rows assert something false, with no
  compiler able to catch it. The honest split is a narrow **write** surface (already true: the
  request schema is `stringLiterals("MOVEUSD")` and `create` types the arg the same way) against a
  **read** type that keeps describing what is stored.
