Devices & POS Terminals

Terminal inventory

MVS Pay keeps an inventory of card-present POS terminals. This survived the Hyperswitch pivot as a first-class MVS concern — Hyperswitch does not model POS hardware, so the Device table lives in the dedicated mvs-pay database and is owned entirely by us.

The single most important thing to understand before you touch this code: the device surface is largely read/inventory + reconcile today. You can list, read, register/upsert, edit metadata, sync from a connector, and delete device rows. You cannot drive the terminal hardware — the lifecycle commands a payfac normally exposes (activate, action, idle-image) are honest 501 stubs left over from the decommissioned Finix integration. They are wired up, authenticated, and documented as not-yet-implemented, deliberately, so callers get a clear 501 instead of a silent call to a dead gateway.

The companion operational guide is the in-repo doc apps/mvs-pay/docs/DEVICE_SETUP.md — registration fields, supported PAX/BBPOS models, the dashboard form, and field-tech troubleshooting. This page is the engineering reference for the data model and routes behind that guide. For exact request/response payloads, the API Reference tab is authoritative.

Where the code lives

Devices span the two main surfaces. The truth is the extensions service; the Next.js app is a thin, scope-checked proxy in front of it.

LayerPathRole
Data modelpackages/mvs-pay/prisma/schema.prisma (model Device)The device table in the dedicated mvs-pay DB
Query helperspackages/mvs-pay/src/queries/devices.tslistDevices, upsertDevice, getDeviceByAnyIdForOrg, updateDevice*, updateDeviceStatus, deleteDeviceForOrg
Extensions routesapps/mvs-pay-extensions/src/routes/devices.tsThe real /v1/devices HTTP surface (mounted in apps/mvs-pay-extensions/src/app.ts)
SDK namespacepackages/mvs-pay-sdk/src/namespaces/devices.tsclient.devices.{create,sync,list,get,update,delete}
Next.js proxy (real)apps/mvs-pay/app/api/devices/**GET/POST /api/devices, POST /api/devices/sync, GET/PUT /api/devices/[id], POST /api/devices/[id]/sale
Next.js proxy (501 stubs)apps/mvs-pay/app/api/devices/[id]/{activate,action,idle-image,idle-image/upload}Lifecycle commands that no longer exist post-pivot

The mounting is plain Hono — api.route("/devices", deviceRoutes) in apps/mvs-pay-extensions/src/app.ts, advertised as devices: "/v1/devices" in the service’s route index. See Extensions Service for the surrounding middleware and the SDK page for the client.

The Device model

model Device in packages/mvs-pay/prisma/schema.prisma (mapped to the device table). Every row is org-scoped, optionally bound to a MerchantExtension, and references the underlying terminal by an opaque connectorDeviceId. MVS application code never holds connector credentials.

FieldTypeNotes
idString (cuid)MVS Pay row id
organizationIdStringFK → PaymentOrganization, onDelete: Cascade. The tenancy key.
merchantExtensionIdString?FK → MerchantExtension, onDelete: SetNull. Optional merchant binding.
connectorDeviceIdString@unique. Connector-side device id (e.g. a Finix device ID via Hyperswitch’s Finix connector). Kept opaque.
hyperswitchMerchantIdString?Opaque Hyperswitch merchant id the device transacts under
tenantPatientId / tenantLocationId / tenantUserId / tenantInvoiceId / tenantPurchaseIdString?Opaque tenant context references — no FKs, indexed only on tenantLocationId
nameStringInternal name (e.g. front-desk)
displayNameString?Friendly label
modelStringHardware model code (e.g. PAX_A920PRO)
serialNumberStringHardware serial
statusString@default("INACTIVE"). Free-form; follows connector-reported state.
enabledBoolean@default(false)
connectionString?Connector-reported connection state (e.g. CONNECTED)
idleMessageString?Idle-screen text (stored; not pushable — see 501 stubs)
descriptionString?Free text
androidVersion / firmwareVersion / paymentAppVersionString?Reported software versions (populated by sync, when a connector reports them)
connectionStatusString?Derived column: CONNECTED / DISCONNECTED
lastConnectedAtDateTime?Set to now() by sync when connection === "CONNECTED"
allowStandaloneSales / allowStandaloneRefundsBoolean@default(false) config flags
promptForSignatureString?NEVER / ALWAYS / ON_THRESHOLD
signatureThresholdInt?Threshold (cents) for ON_THRESHOLD
metadataJson?Arbitrary metadata
createdAt / updatedAtDateTimeTimestamps

Constraints worth knowing:

  • connectorDeviceId is @unique globally (across all orgs), and there is also a composite @@unique([organizationId, connectorDeviceId]).
  • Indexed on organizationId and tenantLocationId.

Several config-ish columns exist on the model but are storage only today — idleMessage, allowStandaloneSales, allowStandaloneRefunds, promptForSignature, signatureThreshold, and the software-version columns. Nothing in the platform pushes these down to a terminal. They are read/write at the row level (via sync or PATCH) but have no hardware effect until a terminal command surface exists. Treat them as merchant-config metadata, not live device control.

Routes — what is real

These are the routes the extensions service actually mounts under /v1/devices (apps/mvs-pay-extensions/src/routes/devices.ts). All are tenant-scoped: every handler reads tenant.organizationId, returns 400 tenant_required if absent, and obtains a per-org client via getMvsPayDb(organizationId).

Method & pathHandler behaviour
GET /v1/deviceslistDevices(...), filterable by merchant_id (→ hyperswitchMerchantId), location_id (→ tenantLocationId), status. Returns { data: [...] }.
POST /v1/devices/syncReconcile or refresh device state (two modes — see below).
POST /v1/devicesRegister/upsert a device keyed on connectorDeviceId.
GET /v1/devices/:idRead one row, org-scoped. 404 not_found if absent.
PATCH /v1/devices/:idUpdate an existing row (org-scoped; 404 if not owned).
DELETE /v1/devices/:iddeleteMany org-scoped; returns { count }.

The @mvs/mvs-pay-sdk DevicesNamespace exposes exactly this surface and nothing morecreate, sync, list, get, update, delete. There is no devices.activate, devices.action, or devices.idleImage method. That absence is the contract.

1// packages/mvs-pay-sdk/src/namespaces/devices.ts — the whole surface
2client.devices.create(input, meta); // POST /v1/devices
3client.devices.sync(input, meta); // POST /v1/devices/sync
4client.devices.list(query, meta); // GET /v1/devices
5client.devices.get(deviceId, meta); // GET /v1/devices/:id
6client.devices.update(id, in, meta); // PATCH /v1/devices/:id
7client.devices.delete(deviceId, meta);// DELETE /v1/devices/:id

Listing the inventory

1const { data } = await mvsPay.devices.list({
2 merchant_id: "mer_xxxxxxxx", // → hyperswitchMerchantId, optional
3 location_id: "LOC456", // → tenantLocationId, optional
4 status: "ONLINE", // optional, free-form match
5});

listDevices (packages/mvs-pay/src/queries/devices.ts) defaults to take: 100, skip: 0, orderBy: { createdAt: "desc" }. The query helper also supports merchantExtensionId, enabled, limit, and offset filters that the HTTP route does not currently expose — if you need them, plumb them through the route, don’t reach around it.

Registering a device

POST /v1/devices is an upsert keyed on connectorDeviceId, not a strict create. Calling it twice with the same connectorDeviceId updates the existing row (this is upsertDevice under the hood). New rows default to status: "INACTIVE" and enabled: false.

1const device = await mvsPay.devices.create({
2 connector_device_id: "dev_xxxxxxxx", // issued by the connector
3 hyperswitch_merchant_id: "mer_xxxxxxxx",
4 tenant_location_id: "LOC456", // optional MVS location link
5 name: "front-desk",
6 display_name: "Front Desk Terminal",
7 model: "PAX_A920PRO",
8 serial_number: "1850000000",
9});

See apps/mvs-pay/docs/DEVICE_SETUP.md for the supported model codes (PAX_A920PRO, PAX_A920, PAX_A800, D135, BBPOS_WISEPOS_E), the dashboard registration form, and the physical pairing steps on the terminal itself.

Device sync & health

POST /v1/devices/sync is the reconciliation entry point a terminal connector / POS agent calls to push device state into MVS Pay. It has two modes, decided by whether the body carries a non-empty devices array:

1

Batch reconcile

The caller sends an array of device states. Each entry is upserted tenant-scoped via upsertDevice, then the connection value is reflected into the derived connectionStatus column. If connection === "CONNECTED", lastConnectedAt is stamped with now(). Response: { synced, devices }.

2

Refresh

No devices array (empty or omitted) → the route re-reads the org’s current devices and returns a consistent snapshot. Response: { synced, devices } where synced is the row count.

Cross-tenant guard. Because connectorDeviceId is globally unique, a tenant could otherwise overwrite another org’s row by colliding on a known connectorDeviceId. The sync handler defends against this: before upserting it runs db.device.findFirst({ where: { connectorDeviceId } }) and, if a row exists under a different organizationId, it silently skips that entry (continue). It does not error. If you’re debugging “my synced device didn’t appear,” check for a connector-id collision against another org first.

What “health” means here

There is no active health-probe or heartbeat job. Device health is entirely connector-reported and pull/push-reconciled, not measured by MVS Pay:

  • connection / connectionStatus reflect the last state the connector told us via sync. MVS Pay never pings a terminal.
  • lastConnectedAt is only advanced on a CONNECTED sync; it is the closest thing to a liveness signal, and it is as fresh as the last sync call.
  • status is free-form and follows whatever the connector reports. The DEVICE_SETUP.md guide describes a REGISTERED → ONLINE ⇄ OFFLINE mental model, but the column is a plain string — nothing in this codebase enforces or transitions those values. Don’t write code that assumes a fixed enum.

A consequence: if a connector stops calling sync, a device row will stay frozen at its last reported connectionStatus indefinitely. There is no TTL or staleness sweep. Surface lastConnectedAt to operators rather than trusting connectionStatus alone.

Terminal sales

A POS sale is not a device command. Post-pivot there is no /v1/devices/:id/sale endpoint on the extensions service. The Next.js route POST /api/devices/[id]/sale (apps/mvs-pay/app/api/devices/[id]/sale/route.ts) is real, but it just creates an ordinary payment intent via payments.create, carrying the originating device in metadata for downstream reconciliation:

1// metadata attached to the payment intent
2{
3 device_id: id,
4 merchant_id: finixMerchantId,
5 source: "terminal",
6 entry_mode: "TERMINAL",
7 receipt_options: body.receiptOptions,
8}

It requires devices:write and a resolved merchant (requireMerchant: true) and validates amount > 0 (integer cents). The extensions service resolves connector routing for the org’s merchant. For the full payment-intent lifecycle see Payments & Refunds and Payment Lifecycle.

The variable name finixMerchantId in this handler is legacy — it’s the auth context’s resolved merchant id, populated under the Hyperswitch model. The name predates the pivot and hasn’t been renamed.

The 501 stubs — device lifecycle is not implemented

These routes exist in apps/mvs-pay/app/api/devices/ purely to return an honest 501 with a clear message. They authenticate (devices:write), await params, and then return not-implemented. They are not wired to the extensions service, the SDK exposes no method for them, and /v1/devices mounts no corresponding surface.

RouteMethodsStatus
app/api/devices/[id]/activate/route.tsPOST, DELETE501 — activate / deactivate terminal
app/api/devices/[id]/action/route.tsPOST501ACTIVATE / DEACTIVATE / CANCEL / REBOOT commands
app/api/devices/[id]/idle-image/route.tsPOST501 — set idle image
app/api/devices/[id]/idle-image/upload/route.tsPOST501 — create file resource + upload URL

Example payload they return:

1{ "error": "not implemented: device action commands are not yet available on mvs-pay-extensions" }

These were the Finix-era device-control endpoints (terminal activate, remote action/reboot, idle-image branding). Finix is decommissioned. The handlers’ own comments spell out the reasoning: returning an honest 501 is preferable to “a silent call to the decommissioned payfac gateway with the wrong secret/header.” They are kept as authenticated placeholders so:

  • callers get an actionable, machine-readable error rather than a hang or a cryptic 5xx from a dead upstream;
  • the route shape is preserved for whenever a real connector terminal-command surface lands on mvs-pay-extensions;
  • there’s no accidental coupling to credentials this service no longer holds.

When a terminal-command surface is built, it belongs on mvs-pay-extensions under /v1/devices/... first, then the SDK namespace, then these proxies — not as a direct connector call from the Next.js app.

Authentication & tenancy

Both the Next.js proxies and the extensions routes are tenant-scoped end to end:

  • Next.js routes call authenticateRequest(request, { requiredScope: ... })devices:read for GET, devices:write for POST / PUT / sync / sale and the 501 stubs. They support both session auth (dashboard) and API-key auth (HealthOS / mvs-c2 integrations).
  • The extensions handlers derive the per-org Prisma client from tenant.organizationId via getMvsPayDb(...) and scope every query/mutation by organizationId. There is no cross-org read path; even GET /:id filters on the org.
  • The cross-tenant connectorDeviceId collision guard in sync (above) is the one place where global uniqueness meets tenancy — get familiar with it.

See Authentication & Tenancy for the scope model and how API keys resolve to an org and merchant.

Gaps & gotchas for the new owner

  • Read-mostly surface. Inventory + reconcile + metadata edits + delete. No hardware control. Don’t promise terminal activation/reboot/branding from MVS Pay until a connector command surface exists.
  • Config columns are inert. idleMessage, signature prompts/thresholds, standalone-sale flags, and version columns are stored but never pushed to hardware.
  • status is a free-form string. The REGISTERED/ONLINE/OFFLINE model in DEVICE_SETUP.md is documentation, not an enforced enum. Validate before relying on specific values.
  • Health is only as fresh as the last sync. No heartbeat, no staleness sweep, no TTL. lastConnectedAt is your best liveness signal.
  • Upsert, not create. POST /v1/devices will quietly update an existing row on a connectorDeviceId match.
  • finixMerchantId naming in the sale handler is legacy — it’s the resolved Hyperswitch merchant.
  • Two filter sets. listDevices supports more filters than the HTTP route exposes; extend the route rather than bypassing it.