Skip to main content

DemozPay — System Gap Report

Snapshot: 2026-05-28 Architecture posture: DemozPay is a bank-to-bank orchestration platform, NOT a wallet/custody system. Money lives in partner bank accounts (Dashen first, then more banks + wallets). DemozPay records obligations + audit + reconciliation. Ledger = truth of EVENTS. Bank = truth of MONEY.

How to use this file: every gap below has a checkbox. As the fix lands, tick the box and add a one-line commit/PR reference. Do not delete entries — close them as [x] FIXED in <commit-sha>. This file is the audit trail of how we closed the bank-orchestrator transition.

Status legend

  • [ ] Open — not started
  • [~] In progress
  • [x] Fixed (must include commit ref + date)
  • [!] Blocked (must include why)
  • [s] Skipped / won't fix (must include why)

0. Master fix-tracker (the headline checklist)

The high-level items to close. Tick as each one lands. Detail for each lives in section 3.

  • GAP-01 Ledger pending/posted lifecycle (schema + RPCs) — §3.1Live. Go ledger now builds + runs end-to-end. Migrations 0001..0004 apply clean. ConfirmSettlement + MarkSettlementFailed proven via verify-s3.sh (PENDING → POSTED with the bank-callback path). Migration 0004_allow_metadata_mutation.up.sql fixed a pre-existing trigger bug that blocked every settlement transition.
  • GAP-02 Disbursement port — async + status methods — §3.4Live. EWA + lending DisbursementPort carry getStatus(reference) + rich DisbursementResult. LedgerPort carries confirmSettlement + markSettlementFailed. Both methods exercised at runtime by SettlementPollerService (S3.1) and the webhook applier (S3.2). 23 jest tests + 4 Go end-to-end tests green.
  • GAP-03 EWA + Loan domain status enums with bank states — §3.3Live. TS enums (SUBMITTED_TO_BANK, ACCEPTED_BY_BANK, BANK_REJECTED) are matched at the DB layer by Prisma migration 20260528020000_bank_orchestrator_enum_statesALTER TYPE "EwaRequestStatus" / "LoanStatus" ADD VALUE. Verified at runtime: verify-s3.sh seeds an EwaRequest directly in status SUBMITTED_TO_BANK and the applier transitions it to DISBURSED.
  • GAP-04 Ledger account taxonomy (drop cash, add payable-to-business-bank + payable-to-FI + payroll-clearing) — §3.8Live. EWA exposes receivableFromEmployee + payableToBusinessBank + feeRevenue + payrollClearing. Lending exposes per-FI payable map + interest + servicing-fee + payrollClearing. Loan domain carries fiPartnerId; migration 20260528000000_add_fi_partner_id_to_loan applies. RecordRepaymentUseCase runtime-uses payableToFiPartnerAccountIdByFi[loan.fiPartnerId] + payrollClearingAccountId for the triple-entry repayment.
  • GAP-05 Disburse use cases rewritten for pre-commit + accepted-not-settled flow — §3.2Live. Both DisburseEwaUseCase + DisburseLoanUseCase already shipped end-to-end. The poll-then-resolve loop is now closed by SettlementPollerService (S3.1) and BankWebhookControllerBankSettlementApplier (S3.2). 23 jest tests cover the path.
  • GAP-06 Outbox event names aligned with bank lifecycle — §3.11Live. New events *.bank_transfer_{submitted,accepted,settled,failed}.v1 are emitted by BankSettlementApplier at runtime. verify-s3.sh asserts ewa.bank_transfer_settled.v1 is appended to outbox_event and that an idempotent replay does NOT emit a duplicate. loan.installment_repaid.v1 + loan.closed.v1 also wired via RecordRepaymentUseCase.
  • GAP-07 Integration-gateway real Go service + first Dashen adapter — §3.7Live. Full Go stack compiles + boots + serves gRPC + processes webhooks. Verified end-to-end by services/bank-sandbox/test/verify.sh: gRPC InitiateDisbursement → real HTTP+HMAC Dashen adapter → bank-sandbox → async signed webhook → gateway transitions PROCESSING → COMPLETED. 3/3 gateway tests + audit (bank_event row count) + S4.1 reconciliation E2E all pass against the running image. Flipping to real Dashen is a GATEWAY_DASHEN_BASE_URL + signing-key swap.
  • GAP-08 Settlement confirmation poller — §3.5Live (S3). apps/api/src/money/integration/settlement-poller.service.ts, gated on SETTLEMENT_POLLER_ENABLED. Cross-tenant sweep via new findByStatuses repo method on EWA + Loan; per-tenant runWithTenant apply phase through BankSettlementApplier. Prometheus counters demozpay_settlement_poller_ticks_total + demozpay_settlement_poller_scanned_total runtime-confirmed.
  • GAP-09 Bank webhook receiver — §3.6Live (S3). POST /api/integration/bank-callback/:partner, HMAC-SHA256 over ${timestamp}.${nonce}.${body} with 5-minute skew. Verified end-to-end in services/bank-sandbox/test/verify-s3.sh: happy-path + idempotent replay + tampered body + missing headers + stale timestamp all pass. Metrics: demozpay_bank_webhook_requests_total{partner,result} runtime-confirmed.
  • GAP-10 Lending repayment use case — §3.9Live (S3). packages/lending/backend/application/record-repayment.usecase.ts + Prisma loan_repayment table (migration 20260528010000_loan_repayment — runtime-applied) + admin endpoint POST /api/loans/:id/installments/:idx/record-repayment. Triple-entry ledger: DR PayrollClearing / CR ReceivableFromBorrower (principal) + CR InterestRevenue (interest). 5 unit tests cover the use case; auto-closes the loan when the last installment is recorded.
  • GAP-11 Bank statement ingestion + drift detection — §3.10Live (S4). Gateway side: services/integration-gateway/internal/reconciliation/ with Dashen CSV ingester, partner-agnostic Matcher, Runner orchestrator, Postgres Store; bank_statement_line table with FORCE RLS + append-only DELETE guard (migration 0002_bank_statement_line.up.sql). Ledger side: new RPC Ledger.ReconcileWithBank(account, period, statement_total)(ledger_total, statement_total, drift, entries_count) — logs ERROR on non-zero drift. 22 Go unit tests + 2 runtime E2E scripts.
  • GAP-12 Legacy Wallet* Prisma models deprecated + lint block — §3.12Live (S4). /// @deprecated triple-slash on Wallet, WalletTransaction, WithdrawalRequest, and LedgerAccount.balance; ESLint no-restricted-imports blocks them from @prisma/client outside apps/api/prisma/** with a message pointing at ADR-006 / LedgerPort. Verified: a temp-violator file in packages/ewa/ triggers the rule with the deprecation message.

The single thing that unblocks everything: GAP-01 (ledger pending/posted). Without it the new model can't be expressed at all.


1. Implementation gap report (by module)

A. EWA (packages/ewa/backend/)

  • Domain (ewa-request.ts, ewa-status.ts) — missing bank-side states. FIXED (GAP-03a) — bank states present in ewa-status.ts:35-38. 6-state lifecycle exists: PENDING → APPROVED → DISBURSED → REPAID / FAILED / REJECTED. No SUBMITTED_TO_BANK / ACCEPTED_BY_BANK / SETTLED / BANK_REJECTED distinction. Today "DISBURSED" means "we got a partner_reference back from the gateway" — which in real bank API terms is only ACCEPTED, not SETTLED. Severity: HIGH.

  • Use case (disburse-ewa.usecase.ts) lines 78–96 — architecturally invalid wallet bookkeeping. FIXED (GAP-05) — pre-commit ledger as PENDING; correct receivable/payable shape. Posts a 3-leg journal entry DR advances_receivable / CR cash / CR fee_revenue BEFORE the bank call. The CR cash leg implies we held the cash. Under bank-orchestrator model, we never had the cash. Correct shape: DR receivable_from_employee / CR payable_to_business_bank. Severity: CRITICAL.

  • Use case lines 100–105 — no async-bank model, no compensation path. FIXED (GAP-05) — compensation path lives in the use case (REJECTED → MarkSettlementFailed + Reverse) and the settlement poller (GAP-08). Calls disbursement.disburse(...) AFTER posting to the ledger. Treats the gateway call as a one-shot synchronous send. If the bank rejects after the ledger is already posted, we have a journal entry recording a settlement that never happened — and no compensation code. Severity: CRITICAL.

  • Ledger accounts (ledger-accounts.adapter.ts) — wallet-model account shape. FIXED (GAP-04a) — payableToBusinessBankAccountId + payrollClearingAccountId at ledger-accounts.adapter.ts:19,21. No per-bank-partner settlement accounts (one per Dashen partner ID, one per CBE, per Telebirr, etc.), no business_pool_account (the funding source), no payroll_clearing_account. Severity: HIGH.

  • Disbursement port (disbursement.port.ts) — single sync method, no status query. FIXED (GAP-02) — getStatus + rich DisbursementResult at disbursement.port.ts:37-43,91. Real bank flow needs getStatus(ref) for polling AND a webhook-receiving endpoint for push notifications. Today there's no way for the use case to learn the bank's final answer. Severity: HIGH.

  • Accrued earnings (payroll-accrued-earnings.adapter.ts) — placeholder, not real payroll signal. FIXED — adapter now resolves (tenantId, payPeriodId)PayrollRun and pro-rates payroll_run_entry.grossSantim against the run's actual periodStartDate..periodEndDate. Terminal-status runs (DISBURSED / COMPLETED / LOCKED) return full gross. Employee.baseSalary is kept as a logged fallback for tenants not yet on the payroll engine. Reads Employee.baseSalary and pro-rates by elapsed days in YYYY-MM. Real eligibility needs: (a) confirmed payroll calendar from the Business, (b) pending deductions from prior EWA/loan/BNPL, (c) employer payroll-cycle status. Severity: MEDIUM.

  • Unit tests — false confidence. FIXED — use case specs now exercise the PENDING/ACCEPTED/REJECTED branches against in-memory adapters that simulate each. 11 tests pass against in-memory ports. Tests can't catch the bank-orchestrator model violation because the in-memory disbursement always succeeds synchronously. Severity: HIGH.

B. Lending (packages/lending/backend/)

Same shape as EWA, same problems:

  • disburse-loan.usecase.ts lines 86–89 — wallet bookkeeping. FIXED (GAP-05) — pre-commit PENDING at disburse-loan.usecase.ts:196. DR loans_receivable / CR cash assumes we held the principal. Real loan model: the partner FI held it, we orchestrated transfer FI→employee, our ledger records the receivable owed to the FI and our servicing fee. Severity: CRITICAL.

  • Loan domain (repayment-schedule.ts) — FINE. Multi-installment RepaymentSchedule with flat-rate amortization, Money.allocate. Schedule arithmetic is Live and correct. Rate-math is independent of custody model.

  • Loan status enum — missing bank states. FIXED (GAP-03b) — loan-status.ts:14-17. PENDING / APPROVED / DISBURSED / CLOSED / REJECTED / DEFAULTED — no SETTLED, no SUBMITTED_TO_BANK. Severity: HIGH.

  • Ledger accounts (LoanLedgerAccounts) — wallet-shape + missing FI-payable. FIXED (GAP-04b) — apps/api/src/products/lending/ledger-accounts.adapter.ts:28 (payableToFiPartnerAccountIdByFi). loansReceivableAccountId / cashAccountId / interestRevenueAccountId. Missing per-FI-partner payable. Severity: HIGH.

  • No repay-loan.usecase.ts exists. FIXED (GAP-10) — packages/lending/backend/application/record-repayment.usecase.ts landed. The disburse use case promises "tells the repayment context what to recover" via outbox event, but no consumer or repay use case exists. Severity: HIGH.

  • 7 unit tests pass — same false-confidence problem as EWA. FIXED — lending specs now exercise PENDING/ACCEPTED/REJECTED/SETTLED branches against in-memory adapters.

C. BNPL

  • Domain package does not exist. No packages/bnpl/.
  • Legacy BNPLPurchase Prisma model is unusable as-is. Decimal(15,2) money fields, no tenantId, not under RLS. Violates ADR-005 + ADR-013.
  • Merchant settlement flow — undefined.
  • Repayment flow — undefined.
  • Severity: Block — no code, no design grounded in bank-orchestrator model.

D. Payroll

  • Domain package does not exist. FIXED — packages/payroll/backend/ Live as of E3 2026-05-29 (CLAUDE.md). Domain + 5 use cases + HTTP controller + Prisma adapters.
  • Legacy Payroll, PayrollEntry models exist but are unusable as-is. Decimal money. No tenantId/RLS. Still in apps/api/prisma/schema.prisma; pending @deprecated pass like the wallet models.
  • No payroll run engine. FIXED — PayrollRun aggregate + use cases shipped (E3).
  • No deduction calculator — which EWA / lending / BNPL all depend on. FIXED — apps/api/src/payroll/* adapters read EWA outstanding + lending installments for the deduction calc.
  • Severity: Block — EWA's accruedEarnings adapter is a stub because there's no payroll engine to replace it with. (Engine now exists; the EWA adapter swap is a follow-up — see the unticked EWA accrued-earnings bullet above.)

E. Integration gateway (services/integration-gateway/)

  • Proto contract complete. packages/contracts/grpc/integration_gateway.proto defines InitiateDisbursement / LookupAccount / GetDisbursementStatus / GetAdapterStatus. 5 status states. Good shape.
  • Go server is a 25-line HTTP /health stub. FIXED (GAP-07) — real gRPC server at cmd/integration-gateway/main.go:104-109.
  • Dashen adapter does not exist. FIXED — internal/adapters/dashen/{dashen,signing}.go shipped.
  • No partner adapters at all. FIXED — Dashen adapter is the first; adapter framework in place.
  • No state machine. FIXED — state machine shipped per GAP-07e.
  • No webhook listener for bank callbacks. FIXED (GAP-09) — apps/api/src/money/integration/bank-webhook.controller.ts.
  • No status polling worker. FIXED (GAP-08) — apps/api/src/money/integration/settlement-poller.service.ts.
  • No reconciliation feed ingestion. FIXED (GAP-11a) — services/integration-gateway/internal/reconciliation/{dashen_ingester,matcher,runner}.go.
  • Severity: CRITICAL — this is the heart of the platform and it doesn't exist. (No longer accurate — gateway is Live; pilot soak remains pending per S4 plan.)

F. Ledger (services/ledger/)

  • Schema Live (runtime-proven): ledger_account, ledger_transaction, ledger_entry, deferred balance trigger, append-only trigger, RLS, partial unique index on reverses_transaction_id. Good shape — schema itself is custody-agnostic.
  • PostTransaction RPC (B1) — code written. Idempotent via (tenant_id, idempotency_key) UNIQUE + request_fingerprint. Atomic and immediate — no pending/posted distinction. This is the gap for bank model.
  • Reverse RPC (B2) — code written. Compensating entry, double-reversal lockout. Acceptable for reversing a posted-too-early entry.
  • GetEntries, GetBalance, ReconcileAccount (B3) — code written. ReconcileAccount is exactly the primitive needed for bank vs. ledger drift.
  • Pending → Posted transition NOT IMPLEMENTED. FIXED (GAP-01) — status column + transition function (0003_pending_posted.up.sql); ConfirmSettlement + MarkSettlementFailed RPCs landed.
  • Bank-statement ingestion / reconciliation matching — none. FIXED (GAP-11) — ingester + matcher in services/integration-gateway/internal/reconciliation/; ReconcileWithBank RPC at services/ledger/internal/server/reconcile_with_bank.go.

G. Outbox (apps/api/src/_infra/outbox/)

  • Outbox table + worker Live. FOR UPDATE SKIP LOCKED claim, BYPASSRLS publisher role pattern, gated by env.
  • Event semantics lie about reality. FIXED (GAP-06) — ewa.bank_transfer_submitted/settled/failed.v1 + lending equivalents replace ewa.disbursed.v1.
  • Zero consumers. FIXED (S4.7) — in-process notification consumer landed at apps/api/src/_infra/notification-consumers/. First handler subscribed: ewa.bank_transfer_settled.v1 → SMS via SMS_SENDER. Poller mirrors the proven payroll-deductions-poller pattern (FOR UPDATE SKIP LOCKED on outbox_event, per-event-state cursor in notification_event_state, max-attempt retry budget). Gated by NOTIFICATIONS_CONSUMER_ENABLED (default false). New handlers register declaratively — no dispatcher/poller changes. The Go services/notifications/ stub is now redundant and a candidate for Deprecation; reconciliation + BI consumers remain separate programs.

H. Reconciliation

  • ReconcileAccount RPC in ledger — code written. Internal-ledger consistency only.
  • No bank-statement ingestion — FIXED (GAP-11a) — Dashen CSV ingester in services/integration-gateway/internal/reconciliation/dashen_ingester.go.
  • No drift detection (ledger vs. bank). FIXED (GAP-11b) — ReconcileWithBank returns drift; matcher flags unmatched statement lines.
  • No adjustment-journal process. DESIGNED — see ADR-015. Implementation plan included (~3.5 engineer-days; no schema migration). Open to implementation.
  • No dispute workflow. DESIGNED — see ADR-016. New packages/dispute/ bounded context + Prisma models (dispute, dispute_event, dispute_sla_policy). Implementation plan included (~5.5 engineer-days). Depends on ADR-015 landing first.
  • Severity: CRITICAL — without this, the whole "we record bank events" thesis collapses. (Core reconciliation primitives are live; adjustment + dispute workflows have approved designs awaiting implementation.)

2. Real bank-to-bank flow diagrams

EWA (under bank-orchestrator model)

┌─ Employee taps "Get 1500 ETB now"

┌──────────────────────────┐
│ apps/api (NestJS) │
│ RequestEwaUseCase │
│ - eligibility check │
│ - persist PENDING │
│ - audit + outbox │
│ (NO BANK CALL YET) │
└─────────────┬─────────────┘

(separate approval step; │
could be auto-approve) │

┌──────────────────────────┐
│ DisburseEwaUseCase │
│ state: PENDING→APPROVED │
│ audit + persist │
└─────────────┬─────────────┘


┌──────────────────────────┐
│ Ledger PRE-COMMIT post │
│ status: PENDING │
│ DR receivable-from- │
│ employee (P+F) │
│ CR payable-to-business- │
│ bank (P) │
│ CR unrecognized-fee (F) │
│ ← entries sum to 0 │
│ ← NOT YET "POSTED" │
│ ← needs status='PENDING' │
│ column on tx (gap) │
└─────────────┬─────────────┘

▼ outside DB txn
┌──────────────────────────┐
│ IntegrationGateway │
│ InitiateDisbursement to │
│ Dashen Bank API: │
│ "transfer 1500 ETB from │
│ Business's pool acct │
│ to Employee's bank acct"│
│ │
│ state: SUBMITTED │
└─────────────┬─────────────┘

┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌─────────────┐
│ ACCEPTED │ │ PROCESSING │ │ REJECTED │
│ by bank API │ │ (async at bank) │ │ at submit │
│ partner_ref │ │ poll OR webhook │ │ │
│ returned │ └──────┬───────────┘ │ ledger │
└──────┬───────┘ │ │ reversal │
│ │ │ tx posted │
▼ ▼ │ │
state: ACCEPTED ┌──────────────┐ │ status: │
ledger remains │ SETTLED │ │ FAILED │
PENDING │ (final) │ └─────────────┘
│ │
▼ │
bank statement │
line confirms │
same partner_ref, │
same amount │
│ │
▼ │
ledger tx flips: │
status: POSTED │
ewa_request: │
status: DISBURSED │
outbox event: │
ewa.bank_transfer_ │
settled.v1 │


FAILED handler:
- reversal in ledger
- ewa_request.status=FAILED
- outbox: ewa.bank_transfer_failed.v1
- alert ops, notify employee

Lending (multi-installment, FI as lender of record)

LoanQuoteUseCase / LoanRequestUseCase (NO money movement — quote + persist PENDING)

DisburseLoanUseCase

Ledger PRE-COMMIT post:
DR receivable-from-employee (P)
CR payable-to-FI-partner (P) ← FI is the real lender; we owe them
Interest is NOT booked here. Interest accrues per installment.

Optional second leg pair if our servicing fee is collected at disburse:
DR receivable-from-employee (fee)
CR servicing-fee-revenue (fee)

status='PENDING'

IntegrationGateway InitiateDisbursement
source: FI partner's account at FI's bank
destination: employee's bank account
metadata: { loanId, lender_of_record: FI_PARTNER_ID }

... ACCEPTED → SETTLED → ledger POSTED
outbox: loan.bank_transfer_settled.v1

At each payroll cycle (separate consumer / job):
RecordRepaymentUseCase per loan installment
DR cash-collected-via-payroll
CR receivable-from-employee
CR interest-revenue (this installment's interest portion)

IntegrationGateway transfer:
source: business pool account (where payroll deductions land)
destination: FI partner's account
amount: this installment's principal portion

... ACCEPTED → SETTLED → installment marked PAID

BNPL (merchant settlement)

Employee buys 10,000 ETB item at merchant
→ BNPL plan chosen: 5 installments of 2,000

(Funding source assumption: a partner FI underwrites BNPL receivables.)

CreateBnplPurchaseUseCase:
Ledger pre-commit:
DR receivable-from-employee (10,000)
CR payable-to-merchant (10,000)
status='PENDING'

Merchant settlement (T+1 or batched):
IntegrationGateway transfer:
source: BNPL settlement account (held by underwriter)
destination: merchant's bank account
amount: 10,000 (or minus our merchant fee)

ACCEPTED → SETTLED
ledger:
DR payable-to-merchant
CR merchant-settlement-payable-to-FI

At each payroll cycle:
RecordBnplRepaymentUseCase
DR cash-collected-via-payroll
CR receivable-from-employee (2000 per installment)

IntegrationGateway transfer to BNPL underwriter

3. Required code changes (backend only)

In priority order. Code only — no docs.

3.1 Add bank-state lifecycle to ledger schema [CRITICAL]

  • GAP-01a New migration services/ledger/migrations/0003_pending_posted.up.sql:
-- Add status column to ledger_transaction:
ALTER TABLE ledger_transaction
ADD COLUMN status TEXT NOT NULL DEFAULT 'POSTED'
CHECK (status IN ('PENDING', 'POSTED', 'REVERSED', 'FAILED'));

CREATE INDEX ledger_transaction_status_idx
ON ledger_transaction (tenant_id, status, posted_at)
WHERE status IN ('PENDING', 'FAILED');

-- The deferred balance trigger continues to apply at INSERT time
-- (regardless of status — pending entries must still sum to zero).

-- Forward-only state transitions:
CREATE FUNCTION ledger_transition_status(
p_tenant_id TEXT, p_tx_id TEXT, p_from TEXT, p_to TEXT
) RETURNS VOID
LANGUAGE plpgsql AS $$
BEGIN
-- Allowed: PENDING→POSTED, PENDING→FAILED, POSTED→REVERSED
IF (p_from, p_to) NOT IN (
('PENDING','POSTED'), ('PENDING','FAILED'), ('POSTED','REVERSED')
) THEN
RAISE EXCEPTION 'illegal status transition % → %', p_from, p_to;
END IF;

UPDATE ledger_transaction
SET status = p_to
WHERE tenant_id = p_tenant_id
AND id = p_tx_id
AND status = p_from;

IF NOT FOUND THEN
RAISE EXCEPTION 'tx % not in status %', p_tx_id, p_from;
END IF;
END $$;

-- Replace the existing ledger_block_mutation trigger with a column-level
-- immutability check: UPDATE only allowed if exactly the status column
-- changed (via the function above). All other columns remain append-only.
  • GAP-01b services/ledger/internal/server/post_transaction.go — accept status parameter, default POSTED (backward-compat), allow PENDING. FIXED — post_transaction.go:57 maps proto status via protoStatusToStore.
  • GAP-01c New services/ledger/internal/server/confirm_settlement.go — RPC ConfirmSettlement(tenant_id, tx_id, partner_reference)PENDING → POSTED. FIXED — file landed.
  • GAP-01d New services/ledger/internal/server/mark_failed.go — RPC MarkSettlementFailed(tenant_id, tx_id, reason)PENDING → FAILED. FIXED — file landed.
  • GAP-01e packages/contracts/grpc/ledger.proto — add the two new RPCs + a LedgerTransactionStatus enum. FIXED — ledger.proto:69 ConfirmSettlement, :74 MarkSettlementFailed, :107 enum.
  • GAP-01f Run buf generate → regenerate stubs. FIXED — Go handlers consume the generated types.

3.2 Pre-commit posting in disburse use cases [CRITICAL]

  • GAP-05 packages/ewa/backend/application/disburse-ewa.usecase.ts + packages/lending/backend/application/disburse-loan.usecase.ts restructured to: FIXED — pre-commit at disburse-ewa.usecase.ts:236 and disburse-loan.usecase.ts:196 (status: 'PENDING').
    1. Post ledger entries with status='PENDING' BEFORE the bank call.
    2. Call gateway.
    3. On ACCEPTED: leave ledger PENDING, persist EWA SUBMITTED_TO_BANK, emit ewa.bank_transfer_accepted.v1.
    4. On REJECTED: call MarkSettlementFailed on the ledger AND Reverse the pending tx, persist EWA FAILED, emit ewa.bank_transfer_failed.v1.
    5. On SETTLED (separate worker / webhook handler): call ConfirmSettlement on the ledger, persist EWA DISBURSED, emit ewa.bank_transfer_settled.v1.

Step 5 is NOT part of the request handler. It happens later via polling worker or webhook receiver.

3.3 Add bank-state EWA + Loan domain states [CRITICAL]

  • GAP-03a packages/ewa/backend/domain/ewa-status.ts: FIXED — ewa-status.ts:35-38 adds SUBMITTED_TO_BANK / ACCEPTED_BY_BANK / BANK_REJECTED / DISBURSED.
export type EwaStatus =
| 'PENDING'
| 'APPROVED'
| 'SUBMITTED_TO_BANK' // NEW
| 'ACCEPTED_BY_BANK' // NEW (ledger still PENDING)
| 'DISBURSED' // means SETTLED (ledger POSTED) — semantic change
| 'BANK_REJECTED' // NEW (terminal; ledger reversed)
| 'REPAID'
| 'FAILED' // catch-all for anything not bank-rejected
| 'REJECTED';

Transition table must be updated. The existing DISBURSED semantic of "we got a partner_reference" is wrong; that's ACCEPTED_BY_BANK. Real DISBURSED is settled.

  • GAP-03b Same shape for packages/lending/backend/domain/loan-status.ts. FIXED — loan-status.ts:14-17.

3.4 Disbursement port — async + status methods [CRITICAL]

  • GAP-02 packages/ewa/backend/application/ports/disbursement.port.ts and packages/lending/backend/application/ports/disbursement.port.ts: FIXED — EWA port disbursement.port.ts:91 getStatus, DisbursementResult :37-43; lending port disbursement.port.ts:69 + :24-30 identical shape.
export interface DisbursementResult {
providerRef: string;
bankStatus: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'REVERSED';
acceptedAt?: Date;
failureCode?: string;
failureMessage?: string;
}

export interface DisbursementPort {
disburse(input: DisbursementInput): Promise<DisbursementResult>;
getStatus(reference: string): Promise<DisbursementResult>;
}

The existing single-method port hides the bank-state machine. Use cases need getStatus(reference) for polling, webhook reconciliation, and retries.

3.5 Settlement confirmation worker [CRITICAL]

  • GAP-08 New file apps/api/src/money/integration/settlement-poller.service.ts: FIXED — file landed (carries the GAP-08 marker at line 29).

NestJS service with @Cron or polling timer. Every ~30s:

  1. Query all ewa_request + loan rows where state is SUBMITTED_TO_BANK or ACCEPTED_BY_BANK.
  2. For each, call disbursement.getStatus(providerRef).
  3. If COMPLETED → ledger ConfirmSettlement + domain state → DISBURSED/SETTLED + outbox event.
  4. If FAILED → ledger MarkSettlementFailed + Reverse + domain BANK_REJECTED + outbox event.
  5. If still pending after threshold (e.g., 24h) → escalate alert.

Operates across tenants → use runWithTenant per row, or a dedicated BYPASSRLS role.

3.6 Webhook receiver endpoint [HIGH]

  • GAP-09 New file apps/api/src/money/integration/bank-webhook.controller.ts: FIXED — file landed.
POST /api/integration/bank-callback/:partner (e.g., /dashen)

Verifies HMAC signature from partner. Normalizes the payload via the partner's adapter. Triggers the same ConfirmSettlement / MarkSettlementFailed flow as the poller. Must be idempotent (banks resend webhooks).

3.7 Integration-gateway real implementation [CRITICAL]

  • GAP-07 Build out under services/integration-gateway/: FIXED — cmd/integration-gateway/main.go is now a real gRPC server (lines 104-109 register handlers), internal/adapters/dashen/ carries dashen.go + signing.go, and migrations/0001_init.up.sql provisions the gateway DB.
services/integration-gateway/
├── cmd/integration-gateway/main.go REWRITE: gRPC server + HTTP webhook listener
├── internal/
│ ├── config/
│ ├── pg/ (for the gateway's own state DB)
│ ├── server/ gRPC handlers for the 4 RPCs in proto
│ ├── statemachine/ disbursement state machine
│ ├── adapters/
│ │ ├── adapter.go PartnerAdapter interface
│ │ ├── dashen/ FIRST real adapter (Dashen Bank API)
│ │ ├── telebirr/ later
│ │ └── ...
│ ├── webhook/ HTTP handler per partner
│ ├── reconciliation/ statement ingestion + matching
│ └── store/ pgx-backed
├── migrations/ own schema for disbursement + bank_event tables
└── go.mod, Dockerfile

Disbursement state schema:

CREATE TABLE disbursement (
id TEXT PRIMARY KEY, -- our internal id
tenant_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
partner TEXT NOT NULL, -- DASHEN, TELEBIRR, ...
rail TEXT NOT NULL,
amount_santim NUMERIC(20,0) NOT NULL,
currency TEXT NOT NULL,
source_account TEXT NOT NULL,
destination_account TEXT NOT NULL,
status TEXT NOT NULL, -- INITIATED, SUBMITTED, ACCEPTED, SETTLED, FAILED, REVERSED
partner_reference TEXT,
last_partner_response JSONB,
failure_code TEXT,
failure_message TEXT,
initiated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
submitted_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
settled_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
UNIQUE (tenant_id, idempotency_key)
);

CREATE TABLE bank_event (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
disbursement_id TEXT NOT NULL REFERENCES disbursement(id),
source TEXT NOT NULL, -- ADAPTER_RESPONSE, WEBHOOK, POLL, STATEMENT_LINE
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

PartnerAdapter interface (Go):

type PartnerAdapter interface {
Initiate(ctx context.Context, req InitiateRequest) (InitiateResponse, error)
GetStatus(ctx context.Context, partnerRef string) (StatusResponse, error)
ParseWebhook(headers http.Header, body []byte) (WebhookEvent, error)
Name() string
}

Dashen adapter: HTTP client with mTLS, request signing per Dashen's spec, response normalization.

3.8 Ledger account taxonomy [HIGH]

  • GAP-04a apps/api/src/products/ewa/ledger-accounts.adapter.ts: FIXED — ledger-accounts.adapter.ts:19,21 expose payableToBusinessBankAccountId + payrollClearingAccountId; cashAccountId gone.
async forTenant(tenantId: string): Promise<EwaLedgerAccounts> {
return {
receivableFromEmployeeAccountId: `${tenantId}:1200-ewa-receivable-from-employee`,
payableToBusinessBankAccountId: `${tenantId}:2100-payable-to-business-pool`,
feeRevenueAccountId: `${tenantId}:4100-ewa-fee-revenue`,
// For settlement-time:
payrollClearingAccountId: `${tenantId}:1050-payroll-clearing`,
};
}
  • GAP-04b Lending — per-FI-partner shape: FIXED — apps/api/src/products/lending/ledger-accounts.adapter.ts:28 carries the payableToFiPartnerAccountIdByFi map.
async forTenant(tenantId: string): Promise<LoanLedgerAccounts> {
return {
receivableFromBorrowerAccountId: `${tenantId}:1300-loan-receivable`,
payableToFiPartnerAccountIdByFi: { // map by FI id
[fiPartnerId]: `${tenantId}:2200-payable-to-fi-${fiPartnerId}`,
},
interestRevenueAccountId: `${tenantId}:4200-loan-interest-revenue`,
servicingFeeRevenueAccountId: `${tenantId}:4210-loan-servicing-fee`,
payrollClearingAccountId: `${tenantId}:1050-payroll-clearing`,
};
}

3.9 Repayment use case (lending) [HIGH]

  • GAP-10 New file packages/lending/backend/application/record-repayment.usecase.ts: FIXED — file landed.

Triggered by a payroll-deduction event (planned consumer of payroll.deductions_taken.v1 event). For each loan installment:

  • Ledger entries: DR payroll_clearing / CR loan_receivable + CR interest_revenue
  • Loan installment status: SCHEDULED → PAID
  • If all installments paid → loan status: CLOSED
  • Outbox: loan.installment_repaid.v1

Then a separate worker forwards payroll_clearing → FI partner bank account via the gateway.

3.10 Bank statement ingestion + drift detection [HIGH]

  • GAP-11a New folder services/integration-gateway/internal/reconciliation/: FIXED — dashen_ingester.go, matcher.go, runner.go present.

    • statement-ingester.go — parses bank statement formats (CSV/MT940/whatever Dashen exports). Insert rows to bank_statement_line table.
    • matcher.go — match each statement line to a disbursement row by (amount, partner_reference, value_date). Mark as RECONCILED.
    • drift-reporter.go — exposes RPC ReportDrift(period) returning unmatched statement lines + unmatched disbursements.
  • GAP-11b On the ledger side: add ReconcileWithBank(account_id, period, statement_total) RPC that computes our derived balance for the period and reports drift. FIXED — services/ledger/internal/server/reconcile_with_bank.go + proto ledger.proto:90.

3.11 Outbox event names [MEDIUM]

  • GAP-06a packages/ewa/backend/domain/events.ts — replace ewa.disbursed with the lifecycle: FIXED — events.ts:28-31 carries bank_transfer_submitted/settled/failed; old ewa.disbursed.v1 removed.

    • ewa.requested.v1
    • ewa.bank_transfer_submitted.v1
    • ewa.bank_transfer_accepted.v1
    • ewa.bank_transfer_settled.v1 ← only NOW can downstreams trust settlement
    • ewa.bank_transfer_failed.v1
    • ewa.repaid.v1
  • GAP-06b Same shape for packages/lending/backend/domain/events.ts. FIXED — events.ts:11-14.

Notification consumer subscribes to *_settled and *_failed, not *_accepted.

3.12 Mark legacy models DEPRECATED in code [MEDIUM]

  • GAP-12a apps/api/prisma/schema.prisma — add @@map comments and @deprecated headers to Wallet, WalletTransaction, WithdrawalRequest, BNPLPurchase (the legacy version). FIXED — schema.prisma:486 (LedgerAccount.balance), :797 (Wallet), :828 (WalletTransaction), :864 (WithdrawalRequest).
  • GAP-12b Add ESLint rule blocking imports of these model types in domain/use-case layers. FIXED — shipped via the stock no-restricted-imports rule in eslint.config.mjs:204-235 (carve-out only for apps/api/prisma/**). Probe import of Wallet / WithdrawalRequest from @prisma/client reports rule-id no-restricted-imports at severity 2.

A future migration removes them. Until then, NEW CODE MUST NOT WRITE to them.


4. Priority order — what to build first in code

OrderGAPItemWhy firstEffort
1GAP-01Ledger status column + ConfirmSettlement + MarkSettlementFailed RPCs (+ proto + buf gen)Nothing else can be correct without pending/posted distinction.2 days
2GAP-02Extend DisbursementPort to include getStatus + async result shapeOnce the ledger supports pending, the disburse use case can pre-commit. Port must surface bank state.0.5 day
3GAP-03, GAP-04Domain status enum changes + ledger-accounts adapter rewriteTouches every use case and adapter. Must land coherently with 1+2.1 day
4GAP-05Rewrite DisburseEwaUseCase + DisburseLoanUseCase for pre-commit + accepted-not-settled flowFirst place the new model becomes visible end-to-end.1.5 days
5GAP-06Rename outbox events to reflect lifecycleSame commit as #4 — events must align with status.0.5 day
6GAP-07Integration-gateway real Go service + first Dashen adapterLongest job. Until it lands, the new disburse path is unverifiable end-to-end.5–8 days
7GAP-08Settlement poller worker in apps/apiCloses the loop. Needs Dashen adapter (#6).1 day
8GAP-09Webhook receiverNeeds #6. Idempotent webhook handling.1 day
9GAP-10Lending repayment use caseIndependent of bank-flow rewrite once #1–4 land. Can run in parallel with #6.1.5 days
10GAP-11Bank statement ingestion + drift detection + ReconcileWithBankLast leg of audit truth. Cannot ship before #6.4–5 days
11GAP-12Mark legacy wallet models deprecated + add lint ruleFinal housekeeping pass.0.5 day

Estimated total: ~18–22 engineer days serial. Parallelizable: #6 + #9 + #10 can run concurrently.

The single thing that unblocks everything: GAP-01 (ledger pending/posted). Without it the new model can't be expressed at all.


5. Risks if shipped as-is (current code on prod)

Brutal honesty. As of 2026-05-28, none of these risks are mitigated.

#RiskSeverityWhy it matters
1Ledger lies about bank reality. DisburseEwaUseCase posts a balanced 3-leg entry with a CR cash leg, then calls the gateway. If the bank rejects, the use case never marks DISBURSED — but the ledger entry was already committed before the gateway call. No compensation path exists. Result: ledger says we paid out cash that never moved.CRITICALFinance month-end shows a cash account credited for sums no bank statement supports. Audit failure.
2DR cash is the wrong shape entirely. We never had the cash; assuming we did mis-attributes ownership. Regulators reading the ledger would see a balance sheet implying fiduciary custody — which we don't have.CRITICALRegulatory misclassification. Could trigger NBE inquiry under different licensing categories than we operate under.
3No idempotency awareness between API + gateway during retries. Gateway client uses input.reference (EWA request id) as idempotency_key — same key as the ledger. Coincidentally safe, but retry logic has no awareness that the ledger ALREADY committed.HIGHWorks by accident. One well-meaning refactor breaks it.
4Webhook receiver does not exist. Banks send settlement confirmation via webhook (Dashen, Telebirr both support). No HTTP listener. Settlement state never reaches the system asynchronously.CRITICALWe run blind on settlement status.
5Status poller does not exist. Without webhooks AND without polling, the system only learns ACCEPTED, not SETTLED.CRITICALWe can never give the employee a "money has arrived" confirmation; we can never reconcile against the bank statement.
6No bank-statement ingestion. Internal-ledger reconciliation (ReconcileAccount) only proves the ledger is internally consistent, not that it matches reality.CRITICALDrift accumulates silently. By month 3, the ledger could be off by millions of santim and nobody would know.
7Legacy Wallet + WalletTransaction Prisma models still in the schema. No new code reads them, but they signal "we have wallets" to any contributor.MEDIUMOnboarding hazard.
8Lending has no repayment use case. Disburse exists; collection at payroll doesn't.CRITICAL for any loan use caseLoans appear as permanent receivables; ledger never zeros them.
9BNPL + Payroll + Savings + Equb have no code at all.HIGHProduct/business must not promise these to partners as near-term available.
10Integration-gateway is /health only. Every flow above assumes it works.CRITICALNo money can move on this platform today.

6. Architecturally-invalid items (the rule: anything assuming wallet balances / internal money movement / non-bank settlement truth)

  • packages/ewa/backend/application/disburse-ewa.usecase.ts lines 86–90{ accountId: accounts.cashAccountId, credit: request.principal } and cashAccountId in chart of accounts. INVALID — implies we held cash. Should be payable-to-business-bank-pool. FIXED (GAP-04a + GAP-05) — cashAccountId removed; PENDING posts use payableToBusinessBankAccountId.

  • packages/lending/backend/application/disburse-loan.usecase.ts lines 87–88 — same DR loans_receivable / CR cash shape. INVALID — should be DR receivable-from-employee / CR payable-to-FI-partner. FIXED (GAP-04b + GAP-05).

  • apps/api/src/products/ewa/ledger-accounts.adapter.ts line 13cashAccountId: `${tenantId}:1000-cash` . INVALID — there is no DemozPay cash account. There are bank settlement positions per partner. FIXED (GAP-04a) — see ledger-accounts.adapter.ts:19,21.

  • apps/api/src/products/ewa/payroll-accrued-earnings.adapter.ts lines 21–35 — reads Employee.baseSalary and pro-rates by elapsed-day count. Placeholder, not invalid — but cannot ship to prod. Real accrual requires payroll-domain integration with employer-confirmed payroll cycles. FIXED — adapter now reads payroll_run + payroll_run_entry (per-employee grossSantim), pro-rated against the run's real periodStartDate..periodEndDate. Fallback to baseSalary only when no run exists for the period.

  • apps/api/prisma/schema.prisma lines ~955–1030Wallet model with balance Decimal @default(0) @db.Decimal(20, 2), WalletTransaction, WithdrawalRequest. INVALID under bank-orchestrator model. FIXED (GAP-12) — all three carry @deprecated + lint-blocked imports.

  • apps/api/prisma/schema.prisma line ~510LedgerAccount.balance Decimal @default(0) @db.Decimal(20, 2) (legacy monolith-side ledger). INVALID — ADR-006 already says no stored balances. Predates the Go ledger. FIXED (GAP-12) — column-level @deprecated at schema.prisma:486.

  • packages/ewa/backend/application/ports/disbursement.port.ts — single sync method disburse → Promise<{providerRef}>. INVALID — no model of async bank state. FIXED (GAP-02) — async-aware port with getStatus and DisbursementResult.bankStatus.

  • packages/ewa/backend/application/disburse-ewa.usecase.ts step ORDER comment lines 39–48 — asserts "1. Approve 2. Post receivable 3. Send cash 4. Mark disbursed" with gateway call AFTER ledger post and NO compensation. INVALID — order itself is wrong. Ledger PENDING first; gateway second; gateway result drives PENDING→POSTED or REVERSED. FIXED (GAP-05) — comment + flow rewritten.

  • services/integration-gateway/cmd/integration-gateway/main.go whole file — 25-line HTTP /health stub instead of gRPC server with adapters. INVALID as a production component. Stub claiming a name. FIXED (GAP-07) — real gRPC server with handler wiring.

  • apps/api/src/products/ewa/ewa-api.module.ts (wiring) — binds DisbursementPort → IntegrationGatewayClient dialing :50052 — service doesn't exist. INVALID at runtime but consistent at contract level. FIXED (GAP-07) — gateway service now exists and answers on the contract address.


7. How to update this file

When a gap is closed:

  1. Change [ ] to [x].
  2. Add a one-line note after the entry: → Fixed in <commit-sha> (YYYY-MM-DD).
  3. Do not delete the entry. This file is the audit trail.

When a gap is in progress:

  1. Change [ ] to [~].
  2. Add a one-line note: → In progress: <branch-name>, <engineer>, started YYYY-MM-DD.

When a gap is blocked:

  1. Change [ ] to [!].
  2. Add a one-line note explaining why and what unblocks it.

When the team decides to skip a gap:

  1. Change [ ] to [s].
  2. Add a one-line note explaining the decision and what was changed instead.

Next step after this file lands: prepare a step-by-step action plan that walks GAP-01 → GAP-12 with concrete tasks per gap, sequencing, parallelisation, and acceptance criteria. That plan is the working document for the bank-orchestrator transition.