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-apiorigin/main(6172310). This supersedes the 2026-09-19 “money-out-only” version of this page (formerlymerchant-verification-money-out-only.md), which is now policy A — still available, but only behind the realm switch below.
The rule, in one line
Section titled “The rule, in one line”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.
Source of truth in code
Section titled “Source of truth in code”- Policy point:
terminales-api/src/pos/merchant-capabilities/merchant-capabilities.service.ts—MerchantCapabilitiesService. Every gated operation calls itsassertCan*methods instead of re-deriving the rule; the pureresolve(merchant, storeCount, policy)is the policy itself, andpolicydefaults 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— keymerchant.unverifiedCanOperate,type: boolean,default: false,scopes: 'realm',editableBy: 'admin',clientVisible: false; es-MX label «Comercios sin verificar pueden operar». Read viaRuntimeSettingsService; 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 }withcode∈merchant_unverified | merchant_suspended | merchant_store_mismatchandgate∈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 aplatformAdmin) returns the resolvedMerchantCapabilitiesDto. Clients mirror it; they never read the switch.
Capability table
Section titled “Capability table”“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
falseand the growth caps are 1/1; under policy A they followisActiveand 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.
Where each gate is enforced (api)
Section titled “Where each gate is enforced (api)”| 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) — seemerchant-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;platformAdminonly; reason ≥ 10 chars). Attestable checks:contactEmail,contactPhone,identity,business,aml(when enabled),bank(confirms the account the merchant already registered).termsis never attestable — the owner accepts them. Web: merchant detail → tab Verificación → «Verificar en nombre del comercio». - Revoke (
…/verification/revoke,platformAdmin): the merchant goeslapsed(manual_revoke); only what the platform attested is undone (an attested bank account returns toPENDING), 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.
The switch — who and how
Section titled “The switch — who and how”- Realm-only, on purpose. There is no per-merchant override: a merchant-scoped write of
merchant.unverifiedCanOperateis rejected (422) and ignored at read. A per-merchant escape hatch is exactly what the retiredallowUnverifiedSalescolumn 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_logrow (actor, before/after, reason).
Rationale
Section titled “Rationale”- 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
allowUnverifiedSalesrows onmanual, 2026-09-12 —decisions— are the failure this avoids). - Fail closed. The switch defaults to
false, a read failure resolves tofalse, andresolve()defaults to policy B.
Edge cases
Section titled “Edge cases”- 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 stucklapsedor wrongly verified is recovered withPOST /pos/admin/merchants/:id/verification/resync(operator). - Verified merchant is suspended → everything stops until an admin reactivates it; verification state is retained.
Open questions
Section titled “Open questions”- None for the rule. Web/api copy for the same gate differs (web
merchantVerification.i18n.tsclick-time sentences vs the apimessage) — 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