Skip to content

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:

  1. “#53” and “#54” are flows-screens-master-map.md gap 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.

  2. The vendor dependency (#54 / Mantarys inbound-credit) is OFF the critical path for bank withdrawal. settlement-requests.service.ts:429 hard-rejects any PLATFORM destination (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.


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 (db1996fc on web main, 2026-09-12) — wired MerchantBankAccountCard edit mode to PUT 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.