Skip to content

Merchant verification policy — what an unverified merchant may do

Purpose: the domain truth for what merchant verification gates. This is the authoritative page; any other doc that describes verification gating must agree with it. Audience: AI agents. Status: as of 2026-09-26. PO decision 2026-09-25 (“B plus the switch”), as implemented on terminales-api origin/main (6172310). This supersedes the 2026-09-19 “money-out-only” version of this page (formerly merchant-verification-money-out-only.md), which is now policy A — still available, but only behind the realm switch below.

By default (policy B) an unverified merchant can set up but cannot operate: it may build its catalog and appearance, hold one store and pair one terminal, but it cannot sell, open shifts, register cash movements or invite staff until it is verified. Money-out always needs verification. A platform admin can verify on the merchant’s behalf, and can flip the whole realm to policy A (unverified merchants operate; only money-out waits) with the runtime switch merchant.unverifiedCanOperate.

  • Policy point: terminales-api/src/pos/merchant-capabilities/merchant-capabilities.service.ts — MerchantCapabilitiesService. Every gated operation calls its assertCan* methods instead of re-deriving the rule; the pure resolve(merchant, storeCount, policy) is the policy itself, and policy defaults to { unverifiedCanOperate: false } (policy B) so a caller that forgets the switch never grants operating rights.
  • Constants (same file): UNVERIFIED_MAX_STORES = 1, UNVERIFIED_MAX_TERMINALS = 1, UNVERIFIED_DEVICE_REQUEST_LIMIT = 1, UNVERIFIED_CAN_OPERATE_SETTING = 'merchant.unverifiedCanOperate'.
  • The switch: terminales-api/src/core/runtime-settings/settings-registry.ts — key merchant.unverifiedCanOperate, type: boolean, default: false, scopes: 'realm', editableBy: 'admin', clientVisible: false; es-MX label «Comercios sin verificar pueden operar». Read via RuntimeSettingsService; any read failure or malformed value resolves to the default, i.e. fails closed to policy B.
  • Coded 403s: terminales-api/src/pos/merchant-capabilities/merchant-unverified.ts — body { statusCode, code, gate, message } with code ∈ merchant_unverified | merchant_suspended | merchant_store_mismatch and gate ∈ sell | open_shift | cash_movement | invite_staff | add_store | pair_terminal | request_devices | withdraw | transfer_out.
  • Client read: GET /pos/merchants/:id/capabilities (merchant-capabilities.controller.ts; the merchant owner or a platformAdmin) returns the resolved MerchantCapabilitiesDto. Clients mirror it; they never read the switch.

“Verified” = merchants.isVerified === true (the composite checklist: owner KYC approved and verified bank account, plus the other required checks — merchant-verification-spine-kyc-integration-design §6). “Active” = isActive !== false and status !== 'suspended' (fail-closed when the row is missing).

Capability (MerchantCapabilitiesDto) Unverified · policy B (default) Unverified · policy A (switch on) Verified Suspended
Sell — cash, card, VAS (canSell) ❌ ✅ ✅ ❌
Open / close shift (canOpenShift) ❌ ✅ ✅ ❌
Register cash movement (canRegisterCashMovement) ❌ ✅ ✅ ❌
Invite staff — issue, resend and accept (canInviteStaff) ❌ ✅ ✅ ❌
Stores (maxStores) 1 unlimited unlimited ❌ (active gate)
Paired terminals, counted across all its stores (maxTerminals) 1 unlimited unlimited ❌ (active gate)
Handheld device requests (maxRequestedDevices) 1 1 unlimited 0
Liquidación (canWithdraw) ❌ ❌ ✅ ❌
Merchant→merchant transfer / wallet→platform top-up (canTransferOut) ❌ ❌ ✅ ❌
Create another merchant (canCreateMerchant) ❌ ❌ ✅ —
Catalog, store appearance, notifications, the verification flow ✅ ✅ ✅ ❌
  • Under policy B the four operating capabilities are false and the growth caps are 1/1; under policy A they follow isActive and the caps are unlimited. The device-request cap and money-out ignore the switch.
  • Card and VAS still have their own operational prerequisites once a merchant may sell (an admin-assigned payment credential on the terminal; a funded wallet or connected platform balance). Those are readiness, not verification.
Gate Call site
sell src/pos/sales/sales.service.ts, src/pos/sales/settlement.service.ts, src/payment-intents/payment-intents.service.ts (assertCanSellForStore); src/vas/sale/vas-sale.service.ts (assertCanSell)
open_shift src/pos/staff/shift.service.ts (assertCanOpenShiftForStore)
cash_movement src/pos/staff/cash-movement.service.ts (assertCanRegisterCashMovement)
invite_staff src/auth/invites/invites.service.ts (issue, resend, both accept paths — checked before any account is created); src/auth/user-management/user-management.service.ts
add_store src/pos/foundations/store-provisioning.service.ts (assertCanAddStore)
pair_terminal src/pos/terminals/terminal-pairing.service.ts (assertCanAddTerminalForStore; partner/PSP terminals count)
request_devices src/device-requests/device-request-allowance.service.ts (assertCanRequestDevices; over-cap → 422 device_request_unverified_limit, re-checked at approve)
withdraw src/wallet/settlements/settlement-requests.service.ts (assertCanWithdraw)
transfer_out src/wallet/wallet.controller.ts, src/platform-connections/platform-topup.service.ts (assertCanTransferOut)

The owner invite (platform provisioning a merchant’s owner) is not gated. Onboarding refuses a second merchant while the owner already has an unverified one (409 «Ya tienes un comercio pendiente de verificación. Complétala antes de crear otro.», src/pos/onboarding/onboarding.service.ts). Denials are counted in merchant_gate_denied_total{action,reason=suspended|unverified|store_mismatch}.

Opening the gate: verify, or verify on behalf

Section titled “Opening the gate: verify, or verify on behalf”
  • The merchant verifies through /admin/verificacion (hosted KYC + the checklist) — see merchant-verification-ux-design.
  • A platform admin verifies on the merchant’s behalf: POST /pos/admin/merchants/:id/verification/attest (src/verification/http/admin/merchant-verification-admin.controller.ts; platformAdmin only; reason ≥ 10 chars). Attestable checks: contactEmail, contactPhone, identity, business, aml (when enabled), bank (confirms the account the merchant already registered). terms is never attestable — the owner accepts them. Web: merchant detail → tab Verificación → «Verificar en nombre del comercio».
  • Revoke (…/verification/revoke, platformAdmin): the merchant goes lapsed (manual_revoke); only what the platform attested is undone (an attested bank account returns to PENDING), what the merchant completed itself is kept; the reason is audit-only. The merchant falls back under the unverified policy.
  • Provisioning (provision-on-behalf) and the bulk import can attest the same checks at creation.
  • Realm-only, on purpose. There is no per-merchant override: a merchant-scoped write of merchant.unverifiedCanOperate is rejected (422) and ignored at read. A per-merchant escape hatch is exactly what the retired allowUnverifiedSales column was.
  • Platform admin only. Written via PUT /platform/settings/:key (src/core/runtime-settings/runtime-settings.controller.ts), audited, reason required (≥ 8 chars in the web). Platform operators can read it. Web: ADMINISTRACIÓN › Configuración (/admin/platform/settings), section Verificación.
  • Default false = policy B. Flipping it is a realm-wide product decision, not an operational fix for one merchant — to unblock a single merchant, verify it (or attest on its behalf).

Suspension — the kill-switch (both policies)

Section titled “Suspension — the kill-switch (both policies)”
  • Who: platform admin only (merchant lifecycle — create / suspend / reactivate / delete; see roles-permissions-and-labels.md § Platform roles). Web: merchant detail header «Suspender comercio» / «Reactivar comercio» (reason ≥ 8 chars).
  • What: the merchant is inactive/suspended; every gate throws merchant_suspended — no selling, operating, growth or money-out, verified or not.
  • Reactivate: restores whatever verification state it held (and therefore whichever policy row applies).
  • Audited: every suspend/reactivate writes an audit_log row (actor, before/after, reason).
  • Set up freely, operate once verified (policy B). A new merchant can prepare everything it needs (catalog, look, a store, a terminal) while verification runs, so it is ready the moment it is approved — without trading, taking cash or growing a team under an unverified identity.
  • Money-out is always gated. Risk concentrates when value leaves the platform; payouts, transfers and wallet-funded top-ups require verification in both policies.
  • One realm-level lever, no per-merchant hatch. If the business wants day-one selling again, it flips the realm to policy A; it never opens one merchant by hand (the un-audited allowUnverifiedSales rows on manual, 2026-09-12 — decisions — are the failure this avoids).
  • Fail closed. The switch defaults to false, a read failure resolves to false, and resolve() defaults to policy B.
  • Card / VAS sale by an unverified merchant → policy B: denied (sell). Policy A: allowed if the operational prerequisite is met.
  • Staff invited before the merchant verifies → the invite may exist, but accepting is refused (invite_staff) until the merchant verifies; the invitee sees «La tienda no puede sumar personal por ahora».
  • Merchant created by the platform → born unverified like any other, except for the checks the admin attested at provisioning.
  • Verified merchant is later revoked or its KYC/bank lapses → it returns to the unverified row of the active policy (under B: selling, shifts, cash movements and invites stop; existing stores/terminals stay, but no new ones beyond the cap).
  • Bank account edited after verification → the bank check lapses (edit ⇒ re-verify, W49b), money-out re-gates; under policy B operating re-gates too. This holds on every edit: since terminales-api #965 each bank write gets its own recompute (<accountId>@<$updatedAt> event key; before, a constant workflow id meant only the first bank event ever ran, so a CLABE edit after an attestation did not lapse the merchant). A merchant left stuck lapsed or wrongly verified is recovered with POST /pos/admin/merchants/:id/verification/resync (operator).
  • Verified merchant is suspended → everything stops until an admin reactivates it; verification state is retained.
  • None for the rule. Web/api copy for the same gate differs (web merchantVerification.i18n.ts click-time sentences vs the api message) — cosmetic, tracked from the 2026-09-26 docs sweep.

Related: merchant-verification-ux-design · merchant-verification-spine-kyc-integration-design · roles-permissions-and-labels.md · runtime-settings.md · merchant-onboarding-and-verification-analysis · multi-platform-roles-admin-operator · decisions · human manuals: manual técnico §4.3, manual de usuario §2.2.5