Appearance
Terminales
New in v0.2. A POS-management surface for card-present collection across physical sucursales (branches). Inspired by Mollie's in-person payments UI.
Live mockup
See the working module at vecnet-dashboard.vercel.app/terminales.
What this module does
Terminales is the single place a merchant manages physical card acceptance: sub-merchant identifiers (sub-MIDs) per branch, the POS devices registered to each branch, and the per-terminal payment history. It is the card-present counterpart to the CNP-heavy /transactions and /finances pages and shares the same data plane underneath — the difference is the surface, not the ledger.
A merchant lands on /terminales to answer three questions:
- How are my sucursales performing today? (volume, count, acceptance per branch)
- Which terminals need attention? (offline, error, low battery)
- What happened on that specific device in the last hour? (per-terminal payment stream)
The hierarchy
A single merchant has many sucursales; each sucursal has many terminales; each terminal produces a stream of card-present transactions. Sucursales are the acquirer-reconciliation unit — each one carries its own sub-MID so settlement legs can be cut per location without sharing one fat merchant identifier.
Sucursales (sub-MIDs)
A sucursal is one physical branch with its own sub-MID. The sub-MID is what the acquirer uses to attribute card-present volume back to a specific location, so each sucursal has its own settlement leg even though all of them roll up to the same merchant entity.
| Field | Type | Purpose |
|---|---|---|
id | string | Internal ID (SUC-MTY-CENTRO) |
subMid | string | Acquirer-assigned sub-merchant ID (vec_smid_a3b8f2) |
name | string | Display name |
city, state, address | string | Physical location |
manager | string | On-site contact |
status | active | inactive | Inactive sucursales are hidden from collection routing |
openedOn | ISO date | First settlement-eligible day |
terminalCount | number | Computed; matches count(terminales where sucursalId = id) |
todayVolume, todayCount | number | Card-present aggregates for the local day |
monthVolume, monthCount | number | Month-to-date aggregates |
acceptanceRate | percent | Card-present acceptance, same formula as the global Acceptance rule (success ÷ (success + declined + failed)) — see Merchant Dashboard rule #1 |
Schema in the dashboard repo: vecnet-dashboard/data/terminales.ts → interface Sucursal.
Terminales (POS devices)
A terminal is a single physical device registered to one sucursal. The module covers three device families that share one journal shape downstream — what differs is the chrome (battery, last-seen heartbeat, mobile-vs-fixed presentation), not the financial side.
| Type | Example device | Form factor | Battery? |
|---|---|---|---|
countertop | Verifone V200c | Fixed teller terminal | No |
mobile | PAX A920 | Handheld | Yes |
tap-to-phone | iPhone Tap to Pay | Smartphone-as-terminal | Yes |
Per-terminal fields:
| Field | Type | Purpose |
|---|---|---|
id | string | Internal ID (TPV-MTY-001) |
serial | string | Device serial (VX520-3F2-9842) |
model | string | Display model name |
type | enum | One of countertop / mobile / tap-to-phone |
sucursalId | string | FK to Sucursal.id |
status | enum | online / offline / error / maintenance |
firmware | string | Device firmware version |
battery | number? | 0–100 for mobile and tap-to-phone, undefined otherwise |
lastSeen | ISO datetime | Heartbeat timestamp (used for offline detection) |
lastPaymentAt | ISO datetime? | Last successful card-present payment |
todayVolume, todayCount, monthVolume, monthCount, acceptanceRate | numeric | Same shape as sucursal aggregates |
installedOn | ISO date | Device commissioning date |
Schema in the dashboard repo: vecnet-dashboard/data/terminales.ts → interface Terminal.
Status states
online— heartbeat within the last 15 minutes, the device is collecting.offline— heartbeat lapse; the device may have lost network or been powered off.error— device reports an internal fault (printer jam, EMV reader fault, key rotation failure). Settlement is unaffected but new collection is blocked.maintenance— ops put the device into a hold state on purpose (e.g. firmware rollout, retiring a unit, swapping SAM cards). Surfaces asMantenimiento.
Status is UI sugar, not a ledger primitive
A terminal's status changes the dashboard, never the ledger. Already-captured payments on a now-offline terminal still settle normally; the state machine is an operational signal for the merchant, not a journal-level fact.
Drill-down behavior
The page itself is intentionally flat — the rich detail lives in a right-side DetailPanel (~520px) that opens when a row is clicked. Same component pattern used on /transactions and /finances.
| Click target | Panel contents |
|---|---|
| A row on the sucursales table | Sucursal header (name, sub-MID with copy button, address, manager), today + month + acceptance KPI strip, nested table of all terminals in that sucursal (compact, click to open the terminal panel) |
| A row on the terminales table (when shown) | Terminal header (id, model, status badge), KPI strip, device-info grid (serial, firmware, type, installed, last-seen, last-payment), back-link to the sucursal, recent payments table |
The recent-payments table inside the terminal panel uses the same row shape as /transactions (transacción id, método with network logo, monto, estado badge). It's a filter over the same data — tx.channel = 'terminal' AND tx.terminalId = <id>.
Data shape (excerpt from vecnet-dashboard/data/terminales.ts)
typescript
export interface Sucursal {
id: string;
subMid: string;
name: string;
city: string;
state: string;
address: string;
manager: string;
status: "active" | "inactive";
openedOn: string;
terminalCount: number;
todayVolume: number;
todayCount: number;
monthVolume: number;
monthCount: number;
acceptanceRate: number;
}
export interface Terminal {
id: string;
serial: string;
model: string;
type: "countertop" | "mobile" | "tap-to-phone";
sucursalId: string; // FK → Sucursal.id
status: "online" | "offline" | "error" | "maintenance";
firmware: string;
battery?: number; // 0-100, only for mobile + tap-to-phone
lastSeen: string;
lastPaymentAt: string | null;
todayVolume: number;
todayCount: number;
monthVolume: number;
monthCount: number;
acceptanceRate: number;
installedOn: string;
}Production storage (proposed)
For the production cut, sucursales and terminales fit naturally into the existing operator-tables tier (§ Operator tables) since they're configuration entities owned by the merchant, not ledger primitives:
sucursales(id, entity_id, sub_mid, name, city, state, address, manager, status, opened_on, ...)withunique(entity_id, sub_mid).terminales(id, sucursal_id, serial, model, type, status, firmware, last_seen_at, last_payment_at, installed_on, ...)withunique(serial).- Heartbeat updates go to a separate writes-only stream so terminal-status flapping doesn't churn the main table — same shape as the listener events in Rails: Listener + Webhooks.
- Daily volume / count aggregates are not stored on the row. They're derived from the same
journals+transactionsqueries that power/transactions, filtered byjournals.metadata.terminal_id. This keeps the operator tables thin and the ledger the single source of truth.
Out of scope (today)
- Device provisioning (key injection, firmware push). Today the dashboard reads state, it doesn't manage devices.
- Per-terminal fee rules. Fees stay at the sucursal sub-MID level for now.
- Real-time terminal location / map view. Sucursal address is text only.