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.1
→
Live. Go ledger now builds + runs end-to-end. Migrations0001..0004apply clean.ConfirmSettlement+MarkSettlementFailedproven viaverify-s3.sh(PENDING → POSTED with the bank-callback path). Migration0004_allow_metadata_mutation.up.sqlfixed a pre-existing trigger bug that blocked every settlement transition. - GAP-02 Disbursement port — async + status methods — §3.4
→
Live. EWA + lendingDisbursementPortcarrygetStatus(reference)+ richDisbursementResult.LedgerPortcarriesconfirmSettlement+markSettlementFailed. Both methods exercised at runtime bySettlementPollerService(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.3
→
Live. TS enums (SUBMITTED_TO_BANK,ACCEPTED_BY_BANK,BANK_REJECTED) are matched at the DB layer by Prisma migration20260528020000_bank_orchestrator_enum_states—ALTER TYPE "EwaRequestStatus"/"LoanStatus" ADD VALUE. Verified at runtime:verify-s3.shseeds anEwaRequestdirectly in statusSUBMITTED_TO_BANKand the applier transitions it toDISBURSED. - GAP-04 Ledger account taxonomy (drop
cash, add payable-to-business-bank + payable-to-FI + payroll-clearing) — §3.8 →Live. EWA exposesreceivableFromEmployee+payableToBusinessBank+feeRevenue+payrollClearing. Lending exposes per-FI payable map + interest + servicing-fee + payrollClearing. Loan domain carriesfiPartnerId; migration20260528000000_add_fi_partner_id_to_loanapplies.RecordRepaymentUseCaseruntime-usespayableToFiPartnerAccountIdByFi[loan.fiPartnerId]+payrollClearingAccountIdfor the triple-entry repayment. - GAP-05 Disburse use cases rewritten for pre-commit + accepted-not-settled flow — §3.2
→
Live. BothDisburseEwaUseCase+DisburseLoanUseCasealready shipped end-to-end. The poll-then-resolve loop is now closed bySettlementPollerService(S3.1) andBankWebhookController→BankSettlementApplier(S3.2). 23 jest tests cover the path. - GAP-06 Outbox event names aligned with bank lifecycle — §3.11
→
Live. New events*.bank_transfer_{submitted,accepted,settled,failed}.v1are emitted byBankSettlementApplierat runtime.verify-s3.shassertsewa.bank_transfer_settled.v1is appended tooutbox_eventand that an idempotent replay does NOT emit a duplicate.loan.installment_repaid.v1+loan.closed.v1also wired viaRecordRepaymentUseCase. - GAP-07 Integration-gateway real Go service + first Dashen adapter — §3.7
→
Live. Full Go stack compiles + boots + serves gRPC + processes webhooks. Verified end-to-end byservices/bank-sandbox/test/verify.sh: gRPCInitiateDisbursement→ real HTTP+HMAC Dashen adapter →bank-sandbox→ async signed webhook → gateway transitionsPROCESSING → COMPLETED. 3/3 gateway tests + audit (bank_eventrow count) + S4.1 reconciliation E2E all pass against the running image. Flipping to real Dashen is aGATEWAY_DASHEN_BASE_URL+ signing-key swap. - GAP-08 Settlement confirmation poller — §3.5
→
Live(S3).apps/api/src/money/integration/settlement-poller.service.ts, gated onSETTLEMENT_POLLER_ENABLED. Cross-tenant sweep via newfindByStatusesrepo method on EWA + Loan; per-tenantrunWithTenantapply phase throughBankSettlementApplier. Prometheus countersdemozpay_settlement_poller_ticks_total+demozpay_settlement_poller_scanned_totalruntime-confirmed. - GAP-09 Bank webhook receiver — §3.6
→
Live(S3).POST /api/integration/bank-callback/:partner, HMAC-SHA256 over${timestamp}.${nonce}.${body}with 5-minute skew. Verified end-to-end inservices/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.9
→
Live(S3).packages/lending/backend/application/record-repayment.usecase.ts+ Prismaloan_repaymenttable (migration20260528010000_loan_repayment— runtime-applied) + admin endpointPOST /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.10
→
Live(S4). Gateway side:services/integration-gateway/internal/reconciliation/with Dashen CSV ingester, partner-agnostic Matcher, Runner orchestrator, Postgres Store;bank_statement_linetable with FORCE RLS + append-only DELETE guard (migration0002_bank_statement_line.up.sql). Ledger side: new RPCLedger.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.12 →Live(S4)./// @deprecatedtriple-slash onWallet,WalletTransaction,WithdrawalRequest, andLedgerAccount.balance; ESLintno-restricted-importsblocks them from@prisma/clientoutsideapps/api/prisma/**with a message pointing at ADR-006 / LedgerPort. Verified: a temp-violator file inpackages/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 inewa-status.ts:35-38. 6-state lifecycle exists:PENDING → APPROVED → DISBURSED → REPAID / FAILED / REJECTED. NoSUBMITTED_TO_BANK/ACCEPTED_BY_BANK/SETTLED/BANK_REJECTEDdistinction. Today "DISBURSED" means "we got apartner_referenceback 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 entryDR advances_receivable / CR cash / CR fee_revenueBEFORE the bank call. TheCR cashleg 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). Callsdisbursement.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+payrollClearingAccountIdatledger-accounts.adapter.ts:19,21. No per-bank-partner settlement accounts (one per Dashen partner ID, one per CBE, per Telebirr, etc.), nobusiness_pool_account(the funding source), nopayroll_clearing_account. Severity: HIGH. -
Disbursement port (
disbursement.port.ts) — single sync method, no status query. FIXED (GAP-02) —getStatus+ richDisbursementResultatdisbursement.port.ts:37-43,91. Real bank flow needsgetStatus(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)→PayrollRunand pro-ratespayroll_run_entry.grossSantimagainst the run's actualperiodStartDate..periodEndDate. Terminal-status runs (DISBURSED / COMPLETED / LOCKED) return full gross.Employee.baseSalaryis kept as a logged fallback for tenants not yet on the payroll engine. ReadsEmployee.baseSalaryand pro-rates by elapsed days inYYYY-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
disbursementalways succeeds synchronously. Severity: HIGH.
B. Lending (packages/lending/backend/)
Same shape as EWA, same problems:
-
disburse-loan.usecase.tslines 86–89 — wallet bookkeeping. FIXED (GAP-05) — pre-commit PENDING atdisburse-loan.usecase.ts:196.DR loans_receivable / CR cashassumes 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-installmentRepaymentSchedulewith 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.tsexists. FIXED (GAP-10) —packages/lending/backend/application/record-repayment.usecase.tslanded. 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
BNPLPurchasePrisma model is unusable as-is.Decimal(15,2)money fields, notenantId, 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,PayrollEntrymodels exist but are unusable as-is. Decimal money. No tenantId/RLS. Still inapps/api/prisma/schema.prisma; pending @deprecated pass like the wallet models. - No payroll run engine. FIXED —
PayrollRunaggregate + 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
accruedEarningsadapter 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.protodefinesInitiateDisbursement / LookupAccount / GetDisbursementStatus / GetAdapterStatus. 5 status states. Good shape. - Go server is a 25-line HTTP
/healthstub. FIXED (GAP-07) — real gRPC server atcmd/integration-gateway/main.go:104-109. - Dashen adapter does not exist. FIXED —
internal/adapters/dashen/{dashen,signing}.goshipped. - 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 onreverses_transaction_id. Good shape — schema itself is custody-agnostic. -
PostTransactionRPC (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. -
ReverseRPC (B2) — code written. Compensating entry, double-reversal lockout. Acceptable for reversing a posted-too-early entry. -
GetEntries,GetBalance,ReconcileAccount(B3) — code written.ReconcileAccountis exactly the primitive needed for bank vs. ledger drift. - Pending → Posted transition NOT IMPLEMENTED. FIXED (GAP-01) —
statuscolumn + transition function (0003_pending_posted.up.sql);ConfirmSettlement+MarkSettlementFailedRPCs landed. - Bank-statement ingestion / reconciliation matching — none. FIXED (GAP-11) — ingester + matcher in
services/integration-gateway/internal/reconciliation/;ReconcileWithBankRPC atservices/ledger/internal/server/reconcile_with_bank.go.
G. Outbox (apps/api/src/_infra/outbox/)
- Outbox table + worker Live.
FOR UPDATE SKIP LOCKEDclaim, BYPASSRLS publisher role pattern, gated by env. - Event semantics lie about reality. FIXED (GAP-06) —
ewa.bank_transfer_submitted/settled/failed.v1+ lending equivalents replaceewa.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 viaSMS_SENDER. Poller mirrors the provenpayroll-deductions-pollerpattern (FOR UPDATE SKIP LOCKED onoutbox_event, per-event-state cursor innotification_event_state, max-attempt retry budget). Gated byNOTIFICATIONS_CONSUMER_ENABLED(default false). New handlers register declaratively — no dispatcher/poller changes. The Goservices/notifications/stub is now redundant and a candidate for Deprecation; reconciliation + BI consumers remain separate programs.
H. Reconciliation
-
ReconcileAccountRPC 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) —
ReconcileWithBankreturns 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— acceptstatusparameter, defaultPOSTED(backward-compat), allowPENDING. FIXED —post_transaction.go:57maps proto status viaprotoStatusToStore. - GAP-01c New
services/ledger/internal/server/confirm_settlement.go— RPCConfirmSettlement(tenant_id, tx_id, partner_reference)→PENDING → POSTED. FIXED — file landed. - GAP-01d New
services/ledger/internal/server/mark_failed.go— RPCMarkSettlementFailed(tenant_id, tx_id, reason)→PENDING → FAILED. FIXED — file landed. - GAP-01e
packages/contracts/grpc/ledger.proto— add the two new RPCs + aLedgerTransactionStatusenum. FIXED —ledger.proto:69ConfirmSettlement,:74MarkSettlementFailed,:107enum. - 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.tsrestructured to: FIXED — pre-commit atdisburse-ewa.usecase.ts:236anddisburse-loan.usecase.ts:196(status: 'PENDING').- Post ledger entries with
status='PENDING'BEFORE the bank call. - Call gateway.
- On
ACCEPTED: leave ledgerPENDING, persist EWASUBMITTED_TO_BANK, emitewa.bank_transfer_accepted.v1. - On
REJECTED: callMarkSettlementFailedon the ledger ANDReversethe pending tx, persist EWAFAILED, emitewa.bank_transfer_failed.v1. - On
SETTLED(separate worker / webhook handler): callConfirmSettlementon the ledger, persist EWADISBURSED, emitewa.bank_transfer_settled.v1.
- Post ledger entries with
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-38addsSUBMITTED_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.tsandpackages/lending/backend/application/ports/disbursement.port.ts: FIXED — EWA portdisbursement.port.ts:91getStatus,DisbursementResult:37-43; lending portdisbursement.port.ts:69+:24-30identical 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 theGAP-08marker at line 29).
NestJS service with @Cron or polling timer. Every ~30s:
- Query all
ewa_request+loanrows where state isSUBMITTED_TO_BANKorACCEPTED_BY_BANK. - For each, call
disbursement.getStatus(providerRef). - If
COMPLETED→ ledgerConfirmSettlement+ domain state →DISBURSED/SETTLED+ outbox event. - If
FAILED→ ledgerMarkSettlementFailed+Reverse+ domainBANK_REJECTED+ outbox event. - 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.gois now a real gRPC server (lines 104-109 register handlers),internal/adapters/dashen/carriesdashen.go+signing.go, andmigrations/0001_init.up.sqlprovisions 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,21exposepayableToBusinessBankAccountId+payrollClearingAccountId;cashAccountIdgone.
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:28carries thepayableToFiPartnerAccountIdByFimap.
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.gopresent.statement-ingester.go— parses bank statement formats (CSV/MT940/whatever Dashen exports). Insert rows tobank_statement_linetable.matcher.go— match each statement line to adisbursementrow by(amount, partner_reference, value_date). Mark asRECONCILED.drift-reporter.go— exposes RPCReportDrift(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+ protoledger.proto:90.
3.11 Outbox event names [MEDIUM]
-
GAP-06a
packages/ewa/backend/domain/events.ts— replaceewa.disbursedwith the lifecycle: FIXED —events.ts:28-31carriesbank_transfer_submitted/settled/failed; oldewa.disbursed.v1removed.ewa.requested.v1ewa.bank_transfer_submitted.v1ewa.bank_transfer_accepted.v1ewa.bank_transfer_settled.v1← only NOW can downstreams trust settlementewa.bank_transfer_failed.v1ewa.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@@mapcomments and@deprecatedheaders toWallet,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-importsrule ineslint.config.mjs:204-235(carve-out only forapps/api/prisma/**). Probe import ofWallet/WithdrawalRequestfrom@prisma/clientreports rule-idno-restricted-importsat 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
| Order | GAP | Item | Why first | Effort |
|---|---|---|---|---|
| 1 | GAP-01 | Ledger status column + ConfirmSettlement + MarkSettlementFailed RPCs (+ proto + buf gen) | Nothing else can be correct without pending/posted distinction. | 2 days |
| 2 | GAP-02 | Extend DisbursementPort to include getStatus + async result shape | Once the ledger supports pending, the disburse use case can pre-commit. Port must surface bank state. | 0.5 day |
| 3 | GAP-03, GAP-04 | Domain status enum changes + ledger-accounts adapter rewrite | Touches every use case and adapter. Must land coherently with 1+2. | 1 day |
| 4 | GAP-05 | Rewrite DisburseEwaUseCase + DisburseLoanUseCase for pre-commit + accepted-not-settled flow | First place the new model becomes visible end-to-end. | 1.5 days |
| 5 | GAP-06 | Rename outbox events to reflect lifecycle | Same commit as #4 — events must align with status. | 0.5 day |
| 6 | GAP-07 | Integration-gateway real Go service + first Dashen adapter | Longest job. Until it lands, the new disburse path is unverifiable end-to-end. | 5–8 days |
| 7 | GAP-08 | Settlement poller worker in apps/api | Closes the loop. Needs Dashen adapter (#6). | 1 day |
| 8 | GAP-09 | Webhook receiver | Needs #6. Idempotent webhook handling. | 1 day |
| 9 | GAP-10 | Lending repayment use case | Independent of bank-flow rewrite once #1–4 land. Can run in parallel with #6. | 1.5 days |
| 10 | GAP-11 | Bank statement ingestion + drift detection + ReconcileWithBank | Last leg of audit truth. Cannot ship before #6. | 4–5 days |
| 11 | GAP-12 | Mark legacy wallet models deprecated + add lint rule | Final 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.
| # | Risk | Severity | Why it matters |
|---|---|---|---|
| 1 | Ledger 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. | CRITICAL | Finance month-end shows a cash account credited for sums no bank statement supports. Audit failure. |
| 2 | DR 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. | CRITICAL | Regulatory misclassification. Could trigger NBE inquiry under different licensing categories than we operate under. |
| 3 | No 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. | HIGH | Works by accident. One well-meaning refactor breaks it. |
| 4 | Webhook receiver does not exist. Banks send settlement confirmation via webhook (Dashen, Telebirr both support). No HTTP listener. Settlement state never reaches the system asynchronously. | CRITICAL | We run blind on settlement status. |
| 5 | Status poller does not exist. Without webhooks AND without polling, the system only learns ACCEPTED, not SETTLED. | CRITICAL | We can never give the employee a "money has arrived" confirmation; we can never reconcile against the bank statement. |
| 6 | No bank-statement ingestion. Internal-ledger reconciliation (ReconcileAccount) only proves the ledger is internally consistent, not that it matches reality. | CRITICAL | Drift accumulates silently. By month 3, the ledger could be off by millions of santim and nobody would know. |
| 7 | Legacy Wallet + WalletTransaction Prisma models still in the schema. No new code reads them, but they signal "we have wallets" to any contributor. | MEDIUM | Onboarding hazard. |
| 8 | Lending has no repayment use case. Disburse exists; collection at payroll doesn't. | CRITICAL for any loan use case | Loans appear as permanent receivables; ledger never zeros them. |
| 9 | BNPL + Payroll + Savings + Equb have no code at all. | HIGH | Product/business must not promise these to partners as near-term available. |
| 10 | Integration-gateway is /health only. Every flow above assumes it works. | CRITICAL | No 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.tslines 86–90 —{ accountId: accounts.cashAccountId, credit: request.principal }andcashAccountIdin chart of accounts. INVALID — implies we held cash. Should bepayable-to-business-bank-pool. FIXED (GAP-04a + GAP-05) —cashAccountIdremoved; PENDING posts usepayableToBusinessBankAccountId. -
packages/lending/backend/application/disburse-loan.usecase.tslines 87–88 — sameDR loans_receivable / CR cashshape. INVALID — should beDR receivable-from-employee / CR payable-to-FI-partner. FIXED (GAP-04b + GAP-05). -
apps/api/src/products/ewa/ledger-accounts.adapter.tsline 13 —cashAccountId: `${tenantId}:1000-cash`. INVALID — there is no DemozPay cash account. There are bank settlement positions per partner. FIXED (GAP-04a) — seeledger-accounts.adapter.ts:19,21. -
apps/api/src/products/ewa/payroll-accrued-earnings.adapter.tslines 21–35 — readsEmployee.baseSalaryand 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 readspayroll_run+payroll_run_entry(per-employeegrossSantim), pro-rated against the run's realperiodStartDate..periodEndDate. Fallback tobaseSalaryonly when no run exists for the period. -
apps/api/prisma/schema.prismalines ~955–1030 —Walletmodel withbalance 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.prismaline ~510 —LedgerAccount.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@deprecatedatschema.prisma:486. -
packages/ewa/backend/application/ports/disbursement.port.ts— single sync methoddisburse → Promise<{providerRef}>. INVALID — no model of async bank state. FIXED (GAP-02) — async-aware port withgetStatusandDisbursementResult.bankStatus. -
packages/ewa/backend/application/disburse-ewa.usecase.tsstep 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.gowhole file — 25-line HTTP/healthstub 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) — bindsDisbursementPort → IntegrationGatewayClientdialing: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:
- Change
[ ]to[x]. - Add a one-line note after the entry:
→ Fixed in <commit-sha> (YYYY-MM-DD). - Do not delete the entry. This file is the audit trail.
When a gap is in progress:
- Change
[ ]to[~]. - Add a one-line note:
→ In progress: <branch-name>, <engineer>, started YYYY-MM-DD.
When a gap is blocked:
- Change
[ ]to[!]. - Add a one-line note explaining why and what unblocks it.
When the team decides to skip a gap:
- Change
[ ]to[s]. - 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.