Merchant self-serve payout — the honest end-to-end chain (D2 ruling)
Owner: terminales-liquidaciones · Date: 2026-09-12 · Trigger: PO ruling D2 — self-serve payout IS in MVP; the merchant must be able to withdraw.
This is the chain from “merchant has an available balance” to “money is in their bank account,” with every step marked [CODE] (built and running), [HUMAN] (a person does it manually today), or [THIRD-PARTY] (a vendor/rail outside our control). It is the honest version of what the D2 ruling actually buys.
⚠️ Two premise corrections up front (both code-verified)
Section titled “⚠️ Two premise corrections up front (both code-verified)”The prior framing — mine, carried forward as “cheap UI, expensive deps #53 + #54” — was partly wrong on the deps. What the code actually says:
-
“#53” and “#54” are
flows-screens-master-map.mdgap numbers, not terminales-api GitHub issues. terminales-api GH #53 = “PosFoundations Store Entity” and #54 = “Terminal Store Extension” — both closed POS-foundations tickets, unrelated. Anyone sizing “#53/#54” off GitHub sizes the wrong work. Use the gap names below. -
The vendor dependency (#54 / Mantarys inbound-credit) is OFF the critical path for bank withdrawal.
settlement-requests.service.ts:429hard-rejects anyPLATFORMdestination (422 “Los destinos de plataforma no están disponibles”). #54 gates only the alternative “abono a plataforma” destination — routing settlement back into a platform spending float. The D2 ruling is “money in their bank account,” which never touches #54. So the scary “the date is not ours, it needs a vendor” risk does not apply to the ruled MVP. It would only apply if Alfredo also wanted pay-out-to-platform-float, which he did not ask for.
Net: the payout MVP is buildable with no vendor dependency and no new money-movement engine — the expensive-looking half isn’t on the path. The genuinely-manual steps are two ops actions (verify the account; execute the transfer), covered below.
The chain: available balance → money in the bank
Section titled “The chain: available balance → money in the bank”| # | Step | Kind | State today |
|---|---|---|---|
| 1 | Card sales settle through the unified pipeline; nightly SettlementRollupScheduler writes a settlement_reports row with netAmount / availableAmount (T+1). |
[CODE] | Built, running, live-verified (real card charge → report on manual). |
| 2 | Merchant opens /admin/settlements, sees the report + available balance. |
[CODE] | Built (W74/W75), shipped read-only. |
| 3 | Merchant has a bank account on file (merchant-bank-accounts, lands PENDING). |
[CODE] api · UI GAP | api CRUD built. Merchant UI (MerchantBankAccountCard.tsx, mounted in MerchantForm.tsx:235) displays account + verification pill + statement upload, but is mock-first read-only — there is NO write mutation; the card’s edit mode does not persist. The merchant add/edit-bank WRITE path is ABSENT (merchants lane, confirmed 2026-09-12). This is a hard blocker for real self-serve. |
| 4 | The bank account is VERIFIED. | [HUMAN] today | Platform-admin PATCH /merchant-bank-accounts/:id/verification → VERIFIED (W49b) — curl/manual-only; no admin ops UI exists (merchants lane, confirmed). Money-safe. KYC-delegated auto-verify (Decision C) confirmed post-MVP — size manual, not KYC. |
| 5 | Merchant clicks “Disponer saldo,” splits the amount to their verified account, submits. | [CODE], gated off | DisposeBalanceModal.tsx built; DISPOSE_ENABLED=false. Hooks already switch mock↔live. This is the ~2–4 day re-enable. |
| 6 | Server creates the settlement request: reserves the money via a WITHDRAWAL debit through the serialized wallet engine, decrements availableAmount, lands IN_PROGRESS. Enforces verified-only (assertDestinationsPayable rejects any non-VERIFIED bank destination). Idempotent (deterministic row id + engine dedup). |
[CODE] | Built, tested (settlement-request-workflow.spec.ts). This is the money-safe core. |
| 7 | Ops actually sends the money to the merchant’s bank (SPEI/transfer) from the terminales concentrator account. | [HUMAN] today | No automated bank rail exists in code. A person makes the transfer out-of-band. Whether ops-manual disbursement is acceptable for MVP is a product/ops call (see decisions). |
| 8 | Ops records the payout reference per destination → request SETTLED, stamps completedAt, audits, notifies the merchant. |
[CODE] | Built (settle()). |
| 8′ | (Failure branch) Ops rejects → compensating reversal credit restores the balance, request REJECTED, merchant notified with reason. |
[CODE] | Built (reject()). Money is never stranded. |
Reading of the chain: everything that touches money is [CODE] and money-safe (reserve-on-request, verified-only, reversible on reject). The two [HUMAN] steps are verification (step 4) and the actual transfer (step 7). There is no [THIRD-PARTY] step on this path — the vendor gap is a different (unrequested) destination.
Sizing, with owners
Section titled “Sizing, with owners”| Dep (master-map gap) | What it really is | Owner | On MVP critical path? | Estimate |
|---|---|---|---|---|
| gap #53 — bank-account verification | api verification built (manual platform-admin PATCH, verified-only enforced downstream). Confirmed remaining, all merchants-lane UI: (a) merchant add/edit-bank write path (currently mock — hard blocker); (b) REJECTED display (web mapper collapses REJECTED→‘pending’, merchant can’t see it distinctly); (c) admin verify-action UI (curl-only today). KYC auto-verify = Decision C, post-MVP. | merchants (all three UI items, owns the surface) + verification (Decision C, later) | Yes — as manual ops + UI, not a new engine. | UI-bound, no vendor date. Merchants folds (a)/(b)/(c) into its lane pending PO’s MVP confirm. |
| gap #54 — platform inbound-credit | Mantarys has no inbound-credit method; only gates pay-out-to-platform-float, which is hard-blocked (422). | platforms/conexiones + Mantarys vendor | No — not the ruled path. | N/A for D2 MVP. A vendor conversation only if Alfredo later wants platform-float payout. |
| W76/W77 UI re-enable | Flip DISPOSE_ENABLED, wire dispose→create-request live, verified-bank-account picker, tests, live verify. Remove the “retiro no disponible” copy shipped in #907 (note it in the PR). |
liquidaciones (me) | Yes | ~2–4 days once #53 UI + verification reachable. |
blocked_by (merchants lane, stable refs — real ticket numbers to be swapped in when the PO greenlights the D2 wave)
Section titled “blocked_by (merchants lane, stable refs — real ticket numbers to be swapped in when the PO greenlights the D2 wave)”- MERCH-BANK-WRITE = terminales-web PR #911 — ✅ MERGED (
db1996fcon web main, 2026-09-12) — wiredMerchantBankAccountCardedit mode toPUT pos/settlements/bank-account→ PENDING; fixed the REJECTED-displays-as-pending collapse; added CLABE checksum validation + the verified→pending re-verify warning. This was the HARD blocker on the W76/W77 re-enable — now cleared. (api #723 ungate also landed:ebe76af.) - MERCH-BANK-ADMIN-UI (SOFT, web) — ops surface for
PATCH :id/verification. curl is the accepted MVP mechanism, so it does NOT gate the money path.
api is done on both (@Get/@Put/@Patch(':id/verification')); these are web-only. Merchants owns them; not spun as formal tickets yet (PO owns D2 sequencing + fleet-twin timing).
What the merchant sees while a withdrawal is in flight (and when it fails)
Section titled “What the merchant sees while a withdrawal is in flight (and when it fails)”Copy states to design now, not at the end (es-MX). The absent-CTA copy from #907 (withdrawNote: “El retiro a tu cuenta aún no está disponible en línea.”) comes out when this ships.
- In flight (
IN_PROGRESS): the money has left “disponible” and is reserved. Copy must say the request is received and being processed, and — honestly — that the transfer is completed by the terminales team (because step 7 is manual), without promising a time we can’t compute. e.g. “Solicitud recibida. Estamos procesando tu retiro; te avisaremos cuando se complete.” Avoid a date/interval (same discipline as the empty-state copy). - Settled (
SETTLED): “Tu liquidación {folio} fue completada.” (already the notification body). The screen should show the payout reference + completedAt. - Failed / rejected (
REJECTED): “Tu liquidación {folio} fue rechazada: {motivo}. El monto se devolvió a tu saldo disponible.” — the reversal is real (step 8′), so the copy must tell them the money is back, not leave them thinking it vanished. - Edge to honor: this is a screen about their money. The failure copy is the hard one and must be explicit that the balance was restored.
One decision to bring back (money-risk, not engineering)
Section titled “One decision to bring back (money-risk, not engineering)”Verified-only vs. any bank account the merchant enters. The code already enforces verified-only (assertDestinationsPayable). My recommendation: keep verified-only — it matches the built behavior, it’s the safe default, and relaxing it is the irreversible-once-shipped direction. If Alfredo wants first-version payout to any entered account, that’s a deliberate loosening of a money control and should be an explicit ruling, not a default. Recommend: verified-only stays.
Second, smaller call folded in: is ops-manual disbursement (step 7) acceptable for MVP, or does Alfredo expect an automated SPEI rail? Manual is what exists; an automated rail is real new work (and its own vendor/rail question). Recommend: manual disbursement for MVP, with the system reserving + tracking + reversing (which it already does).
Sequencing constraint (binding, cross-lane)
Section titled “Sequencing constraint (binding, cross-lane)”Emails on → verification reachable → enforcement backfill runs → merchant genuinely verified. A payout path that requires a verified bank account (step 4) sits downstream of that entire chain. Build against it; do not assume today’s manual-realm state. Settlement activation is downstream of the same email fix.