Skip to content

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:

  1. How are my sucursales performing today? (volume, count, acceptance per branch)
  2. Which terminals need attention? (offline, error, low battery)
  3. 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.

FieldTypePurpose
idstringInternal ID (SUC-MTY-CENTRO)
subMidstringAcquirer-assigned sub-merchant ID (vec_smid_a3b8f2)
namestringDisplay name
city, state, addressstringPhysical location
managerstringOn-site contact
statusactive | inactiveInactive sucursales are hidden from collection routing
openedOnISO dateFirst settlement-eligible day
terminalCountnumberComputed; matches count(terminales where sucursalId = id)
todayVolume, todayCountnumberCard-present aggregates for the local day
monthVolume, monthCountnumberMonth-to-date aggregates
acceptanceRatepercentCard-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.tsinterface 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.

TypeExample deviceForm factorBattery?
countertopVerifone V200cFixed teller terminalNo
mobilePAX A920HandheldYes
tap-to-phoneiPhone Tap to PaySmartphone-as-terminalYes

Per-terminal fields:

FieldTypePurpose
idstringInternal ID (TPV-MTY-001)
serialstringDevice serial (VX520-3F2-9842)
modelstringDisplay model name
typeenumOne of countertop / mobile / tap-to-phone
sucursalIdstringFK to Sucursal.id
statusenumonline / offline / error / maintenance
firmwarestringDevice firmware version
batterynumber?0–100 for mobile and tap-to-phone, undefined otherwise
lastSeenISO datetimeHeartbeat timestamp (used for offline detection)
lastPaymentAtISO datetime?Last successful card-present payment
todayVolume, todayCount, monthVolume, monthCount, acceptanceRatenumericSame shape as sucursal aggregates
installedOnISO dateDevice commissioning date

Schema in the dashboard repo: vecnet-dashboard/data/terminales.tsinterface 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 as Mantenimiento.

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 targetPanel contents
A row on the sucursales tableSucursal 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, ...) with unique(entity_id, sub_mid).
  • terminales(id, sucursal_id, serial, model, type, status, firmware, last_seen_at, last_payment_at, installed_on, ...) with unique(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 + transactions queries that power /transactions, filtered by journals.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.

Vecnet — Build Spec v0.2 · Obsidian Terminal