Skip to content

Upvendo POS (First-Party Register) — Architecture Overview

⚠️ SERVER IS LIVE — MERCHANT SELECTION IS STAGED (test_only)

The first-party POS server API is on upvendo-backend origin/production. Merchant-facing onboarding selection is staged: config/pos-providers.php:51-52 sets upvendo => 'active' => true, 'test_only' => true, so in production only merchants flagged is_test can newly become first-party POS merchants.

Verified against upvendo-backend origin/production (500b6b998) and upvendo-backoffice origin/production (de9bcca0e):

On productionEvidence
POS route fileroutes/api/pos.php, required at routes/api.php:239 — inside the top-level Route::middleware('auth')->group(…) opened at routes/api.php:115. routes/api/ holds backoffice/, dev/, field.php, guest.php, pos.php, print.php, reseller/.
POS controllersapp/Http/Controllers/Api/Pos/ holds PosBootstrapController, PosBusinessDayController, PosChannelOrderController, PosKdsStationController, PosOrderController, PosRefundController, PosStaffController, PosTillController, plus Concerns/.
POS token typeapp/Http/Middleware/TokenType.php:53-56 has case 'pos':, accepting a device token whose DeviceTypes is POS. The subscription gate deliberately excludes POS — SUBSCRIPTION_GATED_TYPES stays ['kds', 'kiosk'] (:31) — but liveness (saveDeviceStats()) tracks POS: LIVENESS_TRACKED_TYPES = ['kds', 'kiosk', 'pos'] (:38, applied :115-117).
POS middlewarebootstrap/app.php registers 28 aliases (:31-58), including pos.staff (PosStaffToken, header X-Pos-Staff-Token, :41), printer.auth (:42) and first-party-pos (EnsureFirstPartyPos, :56).
Back-office surfaceupvendo-backoffice src/pages/pos/ holds five pages — registers/index.vue, layouts/index.vue, layouts/[id].vue, fdm-devices/index.vue, reports/index.vue. staff/index.vue is gone: the Staff PINs roster was retired on 2026-07-30 and folded into Settings → Team → Users ("POS access" panel); /pos/staff now redirects to /settings/team/users (src/plugins/1.router/additional-routes.ts:70-74). A sixth first-party surface lives outside pages/pos/: Printer Profiles at /device-management/printer-profiles. Each carries a literal backend permission and firstPartyPosOnly (src/@layouts/plugins/casl.ts:88, enforced at :103 for nav and :168 for direct navigation — since #2427 the route guard uses to.matched.every(...), so route-level gates are genuinely enforced). The device-type picker offers POS, Kiosk, Kitchen Display and Printer, with no isTestEnv gate (src/constants.ts:11-42).
Still no billing SKUapp/Enums/SubscriptionProductSku.php:17-22 = kiosk_first, kiosk_additional, kds, loyalty_program, online_ordering, upvendo_magic — no POS SKU. app/Services/BackOffice/DeviceService.php:769-771: "Printer and POS are intentionally omitted — POS devices are FREE for now".

What is actually closed. The upvendo provider is now 'active' => true, 'test_only' => true (config/pos-providers.php:51-52) — it is staged, not off. active is checked first and is the hard kill-switch; test_only is then checked against the merchant's is_test flag, and both run before the first_party bypass in both branches of ResellerService::getMerchantResellerProviders() (app/Services/BackOffice/ResellerService.php:675 and :731). So in production the "Upvendo POS" tile reaches the onboarding provider list of test-flagged merchants only. Selection is enforced server-side as well: FirstPartyPosProvisioningService::selectFirstPartyPos() 403s a non-test merchant in production with "Unsupported POS provider." while test_only is set (FirstPartyPosProvisioningService.php:70-77). It is the only writer of the pos_provider='upvendo' marker (:59), reached from that route and from the demo seeder (PosDemoSeederService). Going GA means dropping test_only; active => false would hide it from everyone again, test merchants included.

What the gate does and does not reach. A merchant without the marker gets a 403 from first-party-pos on every back-office POS/KDS route group except POST /pos/select-provider (routes/api/backoffice/pos.php:39-40, ungated by design — gating it would make it impossible to ever become first-party), and a 403 from DeviceService::store() when creating a POS or Kitchen Display device (DeviceService.php:749-761). The device-facing /pos/* API carries no first-party-pos gate — it requires only auth + capacitor.auth + type:pos + tenant:pos (routes/api/pos.php:17) — so a merchant already holding the marker runs the whole register end to end.

DeviceTypes::POS = 'POS' (app/Enums/DeviceTypes.php:9) is a live device type, not an inert placeholder. It is the value type:pos matches on, and the back office does offer a POS tile (src/constants.ts:17) — disabled with a tooltip unless the merchant is first-party (FIRST_PARTY_POS_REQUIRED_DEVICE_TYPES, src/constants.ts:57, consumed at src/views/devices/components/dialogs/SelectDeviceTypeDialog.vue:44-50). The enforcement is server-side (DeviceService.php:749-761, #1530; extended to Kitchen Display by #1531); the tile gate alone "was never protection on its own" (DeviceService.php:747-748).

Do not offer Upvendo POS to a merchant — they cannot select it. But the server is real: the contract below is verified production behaviour, and a bug in /pos/* is an ordinary backend bug to fix.

Repository: upvendo-pos (github.com/Upvendo/upvendo-pos) Branch read for this doc: origin/productionRelated: Frontends Overview · Backend Overview · Proxy Overview


What it is

A staff-operated point-of-sale register for iPad. The operator flow is: activate the device → sign in with a staff PIN → open a till → take orders on the register → fire to kitchen → settle in cash with a fiscal signature displayed on screen.

The app follows the kiosk house pattern — a Tailwind consumer SPA — and explicitly not the Vuetify/Vuexy admin pattern used by the back office (CLAUDE.md:12-13,17).


Technology stack

ComponentChoiceCite (upvendo-pos origin/production)
FrameworkVue 3 <script setup>, Composition APIpackage.json:37 (vue ^3.5.21)
LanguageTypeScript, strictpackage.json:54 (typescript ^5.9.2)
BuildVite 7 (vite.config.mts)package.json:55 (vite ^7.3.2)
StatePinia 2package.json:36 (pinia ^2.3.1)
Routingvue-router 4, explicit routespackage.json:39
i18nvue-i18n 11, en onlypackage.json:38
StylingTailwind CSS 3 + SCSSpackage.json:52-53
TestsVitest (jsdom)package.json:50,56
RuntimeNode >= 22, npm >= 10package.json:5-8

There are no auto-imports — every import is explicit, unlike the kiosk's unplugin setup (CLAUDE.md:42-43).


Native shell

A Capacitor 7 wrapper, iOS only.

  • appId: 'com.upvendo.pos', appName: 'Upvendo POS', webDir: 'dist' (capacitor.config.ts:23-25).
  • No Android target. package.json:26-35 lists @capacitor/ios but no @capacitor/android; the config header states "NO Android target (POS is iPad-only this wave)" (capacitor.config.ts:3-9).
  • iPad landscape-first, edge-to-edge: contentInset: 'never', allowsLinkPreview: false (capacitor.config.ts:56-63). CSS env(safe-area-inset-*) owns all spacing.
  • Capgo OTA with a deliberately deferred posture: autoUpdate: 'onlyDownload', resetWhenUpdate: true, autoDeleteFailed: true, autoDeletePrevious: true (capacitor.config.ts:47). directUpdate is deprecated and was intentionally removed — it is subsumed by the string mode. The earlier autoUpdate: true is the 'atBackground' mode, which auto-applied the downloaded bundle on the next app background (directUpdate: false never deferred that; it only ruled out apply-on-foreground). Since every iOS permission dialog backgrounds the app, the webview reloaded mid-first-run and Pinia re-seeded from empty localStorage — the TestFlight-build-2 device-bounce regression. This is a native (baked) setting, so changing it requires a new binary, not an OTA. A downloaded bundle is marked "next" and applied on the next safe app resume — never mid-order. The app surfaces an in-app "update ready" banner (hidden on the register screen) so the operator applies it at a safe moment (src/native/liveUpdate.ts:6-11). notifyAppReady() runs at boot or Capgo rolls the bundle back (src/native/liveUpdate.ts:24).
  • Runtime gating, not build flags: the same JS bundle ships to the web preview and the native shell; every native branch is behind Capacitor.isNativePlatform() (src/composables/useNative.ts:14-19). Native modules live in src/native/ and are dynamic-imported so they stay out of the web bundle.

Deployment status

TestFlight builds have shipped (project version 4, team 42SJZQ2D3F) and the app has been exercised on physical iPad hardware (2026-07-26, iPadOS 26.5.2). The POS repo's CLAUDE.md "built but not shipped / NOT yet archived" note is stale — do not re-cite it.

Current distribution state lives in App Store Connect and Capgo and is not determinable from these repositories, so treat "which build is on which channel right now" as an out-of-repo fact.


Environments

One build per environment, with the backend base URL baked in at build time.

EnvScriptBaked VITE_BACKEND_API_ENDPOINT
testingnpm run build:testinghttps://proxy.testing.upvendo.com/api
stagingnpm run build:staginghttps://proxy.staging.upvendo.com/api
productionnpm run build:productionhttps://proxy.upvendo.com/api

Scripts at package.json:12-14; the production value at .env.production:15.

The /api suffix is load-bearing. The client builds request URLs as ${VITE_BACKEND_API_ENDPOINT}/pos/* with no /api anywhere in code (src/api/instances/fetchInstance.ts:49-50,150), and the proxy serves the Laravel API under /api/*. Dropping the suffix produces 404s on every call. Also note the production proxy host is proxy.upvendo.com — there is no proxy.production.upvendo.com (.env.production:10-12).

Each .env.<env> is committed and non-secret; the VITE_SECURE_CAPACITOR_API_KEY and CAPGO_TOKEN live in a gitignored .env.<env>.local (.env.example:19-29).

There is also a mock mode (VITE_ENV=mock) that serves canned fixtures from src/api/mock/ via a dynamic import, so the mock adapter never enters a real build (src/api/instances/fetchInstance.ts:38,140-148).


Sentry is errors-only — no tracing, no session replay — and consent-gated.

  • It stays off until the merchant enables the performance/analytics cookie category. That consent is read once at boot, so a revocation takes effect on the next launch.
  • Gated on VITE_SENTRY_DSN; never initialised in mock mode and never on a plain dev server.
  • environment = VITE_ENV; release = the Capgo bundle id VITE_BUNDLE_VERSION.
  • sendDefaultPii: false.
  • Request and response bodies are never sent. This is deliberate and load-bearing: the pin-login body is {pin} and escalation carries {manager_pin}.
  • Authorization, X-Pos-Staff-Token, X-Capacitor-API-Key and cookies are stripped from every event.

On-device caches

All local storage keys are tenant-keyed by a merchant_slug::location_id signature and wiped on signature mismatch, so a register re-provisioned to another location can never surface the previous tenant's data.

KeyHolds
pos:outbox:v1Durable outbox (offline capture)
pos:* (register state)Parked orders / register working state
Catalog cacheBootstrap menu for offline browsing
Till / staff session stateCurrent till + staff session
Consent stateCookie-category consent, read at boot

Screen flow and route guards

Routes are declared explicitly in src/router/index.ts:18-75.

The register, orders and transactions tabs are nested under a persistent shell, not flat routes. The gate meta lives on the parent / record and Vue Router merges matched-record meta into to.meta, so the whole shell is uniformly the "till is open" world.

text
/activate  ──► /setup/first-run ──► /pin ──► /till ──► / (ShellLayout)
   │                                                      ├─► register  (default)
   │                                                      ├─► orders
   │                                                      └─► transactions
   │                                            ┌─► /refunds
   └─ /setup/permissions (settings re-entry)    ├─► /business-day
                                                └─► /till/close
RouteNameMeta guardsCite
/activatedevice-activationnone:21-24
/setup/first-runfirst-runrequiresDevice:26-31
/setup/permissionssetup-permissionsrequiresDevice:33-38
/pinstaff-pinrequiresDevice:40-44
/tilltill-openrequiresDevice, requiresStaff:46-50
/till/closetill-close+ requiresTill:52-56
/— (ShellLayout)requiresDevice, requiresStaff, requiresTill:63-84
/''redirect → register:71
/registerregisterinherited from parent:72-76
/ordersordersinherited from parent:77-81
/transactionstransactionsinherited from parent:82-86
/refundsrefundsrequiresDevice, requiresStaff:88-92
/business-daybusiness-dayrequiresDevice, requiresStaff:93-97

The shell itself provides the BottomNav, the MoreSheet and the connectivity banner.

The global beforeEach (:78-133) enforces, in order:

  1. Device gate — no device token → /activate (:83-85).
  2. First-run gate — once activated, the permissions → privacy sequence must complete before any other device-gated screen, including PIN. The two /setup/* routes are exempt so the flow can run (:92-100). Once complete it is never re-shown (:103-105).
  3. Staff gate — no staff token → /pin (:107-109).
  4. Till gateGET /pos/tills/current is asked once per app session (isLoaded), then kept in sync by the till store. The gate keys on hasTillSession (OPEN or ENDED), not on "open till": an ENDED till still has to be counted and closed, and the backend refuses a new till on the same receptacle until it is — so gating on "takes cash" would dead-end the operator on /till. No till session → /till; a till session while on /till → the shell.

Additionally, step 1a: on native, a missing device token triggers exactly one recoverNativeDeviceSession() Keychain re-hydrate before bouncing to /activate.

1. Device activation

POST /device-auth/activate with {activation_code} — the same endpoint the kiosk uses (src/api/services/device-services.ts:4-12). Response: {token, device_details: {id, location_id, merchant_slug}} (src/types/pos.ts:7-18).

Persisted to localStorage under pos_device_token and pos_device_details (src/stores/device.ts:5-6,22-46). The device token is long-lived.

2. Staff PIN

POST /pos/staff/pin-login with {pin}{staff_token, staff}, tolerating extra fields (src/api/services/pos-services.ts:18-20, src/types/pos.ts:26-30).

Persisted to sessionStorage under pos_staff_token, pos_staff, pos_staff_caps (src/stores/staff.ts:5-7,38-48,63-81). Session storage is deliberate: closing the app ends the staff session while the device stays activated (src/stores/staff.ts:38-42). Sign-out fires a best-effort POST /pos/staff/pin-logout (FDM work-out event) with a 2 s timeout and swallows every failure including a 404 (src/api/services/pos-services.ts:22-36, src/stores/staff.ts:83-94).

3. Till

POST /pos/tills with {mode, opening_float} (src/api/services/pos-services.ts:82-87).


Authentication headers

Built in src/api/instances/fetchInstance.ts:72-101, documented at :6-9:

HeaderWhenCite
X-Capacitor-API-Keyalways (when VITE_SECURE_CAPACITOR_API_KEY is set):78-81
Authorization: Bearer <device token>when the device store holds one:83-86
X-Pos-Staff-Token: <staff token>when the staff store holds one:88-91

401 handling is a heuristic

handleUnauthorized() (:103-115) branches on whether the failing request carried a staff token:

  • carried a staff token → assume the staff session expired → clear staff, route to /pin.
  • no staff token → assume the device token is invalid → clear staff and device, route to /activate.

If the device token is the dead one, the follow-up (staff-token-less) PIN login 401s and falls through to the second branch. clearSession() is deliberately network-free so a 401-driven teardown cannot loop into another failing request (src/stores/staff.ts:96-109).

409 handling

A 409 throws VersionConflictError carrying the server payload (:128-130), which the order store interprets as a version conflict. See below.


Order concurrency model

Orders are full-document PUTs, not patches.

text
PUT /pos/orders/{clientUuid}
{
  "expected_version": 3,
  "order_profile": { "behavior": "dine_in" },
  "table_number": "12A",
  "lines": [ { "uuid": "...", "item_id": "...", "qty": 2, "unit_price": 450,
               "price_source": "catalog", "modifiers": [] } ]
}

Payload shape at src/types/pos.ts:338-346, built at src/stores/order.ts:258-277. order_profile is an object {behavior} and table_number crosses as a string (supports "12A"-style tables) — see the contract note at src/stores/order.ts:261-264. The client generates client_uuid and every line uuid via crypto.randomUUID(), with a hand-rolled v4 fallback for older / non-secure-context WebViews (src/utils/uuid.ts:6-15).

409 = version conflict. Only a true version conflict is retryable: refetch via GET /pos/orders/{clientUuid}, adopt the server's version, reapply the local intent (the local lines are the intent for a full-document PUT), and retry once (src/stores/order.ts:288-301). A business-rule 409 — for example void-on-empty against a fired order — is not retryable and is rethrown, after a best-effort refetch-and-reconcile so the UI cannot show an empty list for an order that still has lines server-side (src/stores/order.ts:316-323).

The server owns version, totals, state, fired, tender and fiscal; the local lines array is the pending intent. Totals are never computed client-side — the store's totals is populated from the server response only (src/stores/order.ts:100-110, CLAUDE.md:343-347).

Orders v2 — the omni-channel hub

The Orders hub merges two lanes into one list:

  • Lane A — the register's own open tabs (GET /pos/orders?state=open).
  • Lane B — omni-channel orders from every other channel (GET /pos/channel-orders).

src/utils/channelOrders.ts is the single framework-free source of truth shared by the store, the rail, the cards and their tests. Nothing there imports Vue or Pinia; money is cents and datetimes are ISO strings.

The fulfillment rail

Five buckets, mirroring Square: all | dine_in | self_serve | pickup | delivery.

Assignment is a strict top-down precedence, and the ordering is load-bearing — the first match wins, so a kiosk delivery order is Delivery, not Self-serve (channelOrders.ts:179-190):

  1. Deliverydining_option === 'Delivery'
  2. Self-serveorder_channel ∈ {Kiosk, Table Qr Ordering}
  3. Dine-inFor Here (legacy Dine In), plus Lane A dine_in
  4. PickupPickup / Takeout / Takeaway, plus Lane A take_away

Lane A orders are the register's own, so they never resolve to self-serve or delivery (laneAFulfillment, :195-198).

Counts

  • Fulfillment counts are computed client-side from the rows in hand.
  • Source counts come from the server (channels in the envelope).
  • All uses the envelope total, not a client tally.

Both fulfillment and channels are always JSON objects — {} when empty, ksorted, with zero-count channels omitted entirely.

Status pills

Five kitchen pills driven by KDS staff: Queued, In Progress, Ready, Complete, and '', plus hold and priority flags. Tone rule: only one blue pill at a time.

Views and sorting

Three tab views — active, scheduled, completed (the view query param). Five sort keys; note that active lists sort oldest-first (urgency) while completed lists sort newest-first. interleaveActiveRows merges Lane A and Lane B into the unified Active list under one urgency-first sort.


Offline-first (capture and replay)

This section was rewritten on 2026-07-27. The POS previously had no offline capability; origin/production now ships a complete offline-first stack. Any older note claiming "no service worker, no persisted queue, @capacitor/preferences not wired" is stale.

Connectivity state machine

ConnectivityState = 'online' | 'suspect' | 'offline' | 'recovering' (src/stores/connectivity.ts:61), driven only by real request evidence reported from the transport boundary:

  • Any HTTP response, of any status, counts as reachable. A 401, 404, 502 and 200 are equally proof the link is up. This is load-bearing rather than a convenience: staff tokens expire after 12h, so a register left offline overnight gets 401 Invalid POS staff token from the very first probe once connectivity returns. If leaving OFFLINE required a 2xx, the machine would wedge in OFFLINE forever on a perfectly healthy network.
  • A 5xx is never offline evidence — the proxy answered. Treating it as offline would flip registers offline during a rolling deploy or an fpm-starvation window; the correct behaviour there is the order store's transient-retry backoff.
  • Only a fetch() rejection is offline evidence. K=2 consecutive network failures ⇒ OFFLINE.
  • Recovery probes GET /pos/business-day on a jittered backoff (5–15s for the first 3 attempts, then 30–60s) with a 10s probe budget; concurrent callers join the in-flight probe.

Durable outbox

Keyed pos:outbox:v1 (schema v1), holding one full-document upsert snapshot per orderrecord() replaces rather than appends. Tenant-keyed by merchant_slug::location_id and wiped on signature mismatch, so one register's work can never replay into another tenant.

  • Retention: 200 entries / 7 days, age evicted first (OUTBOX_MAX_ENTRIES, OUTBOX_MAX_AGE_MS).
  • Entry statuses: pending, replaying, conflict, item_missing, orphaned.
  • An empty-lines payload is dropped locally and never replayed.

Durable storage seam

@capacitor/preferences (UserDefaults) on native, localStorage on web. Writes are coalesced per key with a 300ms trailing debounce and a 2000ms hard max-defer, with an explicit flush() on park, sign-out, settle and backgrounding (src/utils/durableStorage.ts).

Offline gating

Blocks fire, settle, till, pricing, refund and businessDay — everything only the server can do. Pure local intent stays live: browsing the cached catalog, building and editing lines, parking, recalling, multiple orders, provisional totals.

Copy is market-specific because the reason differs. In Belgium fire is an FDM P signOrder and settle an N signSale, both minted server-side — an unsigned fiscal output is not a lawful one, so the message is a legal statement, not "reconnecting…". In a non-fiscal market (NL now, US later) the same controls are blocked for ordinary technical reasons. An absent or unrecognisable fdm block is read as FISCAL — the strictest interpretation wins.

Gating keys off OFFLINE only, not isDegraded: a RECOVERING register stays usable.

Replay

Mounted in App.vue (not the shell), edge-driven on four triggers, with a 30s retry cooldown and a 0–10s jittered post-replay revalidate (src/composables/useOutboxReplay.ts).

Still true: one in-flight mutation per device

The in-memory serialising promise chain (src/stores/order.ts:66-91) remains: every server mutation is enqueued onto a single chain so rapid taps cannot interleave and race expected_version. Links are settle-safe (rejections are caught at the link) and the next task runs regardless of the previous outcome. It is one in-flight mutation per device — durability across a reload is now the outbox's job, not the chain's.

Debugging pointers

  • An OFFLINE register with pending work transitions to RECOVERING, not straight to ONLINE.
  • Check the outbox hasPending flag before any device or staff teardown — tearing down with pending entries strands them.

Cash only

Settlement takes a cash tender and returns change:

text
POST /pos/orders/{clientUuid}/settle
{ "amount_tendered": 5000 }

src/api/services/pos-services.ts:71-78; response carries tender: {amount_tendered?, change?} on the bare order resource (OrderTender, src/types/pos.ts:348-355; carried as tender? on OpenOrderResource, :420). The settle dialog is titled "Cash payment" (src/plugins/i18n/locales/en.ts:331-341) and the change display prefers the server's tender.change (CLAUDE.md:346-347).

Card payment is out of scope for this wave:

  • Card refunds are blocked in the client with an explicit message: "This order was paid by card — card refunds run via the Stripe follow-up flow, not from the POS." (src/plugins/i18n/locales/en.ts:299, rendered at src/pages/Refunds.vue:199-203, flag set from a typed 422 at src/stores/refund.ts:127).
  • The Bluetooth / card-reader permission row is a muted, non-interactive "coming soon" row that is never prompted (src/composables/usePermissions.ts:73-82, tag copy at src/plugins/i18n/locales/en.ts:116-120). The security gate behind this is explicit: iOS cannot show a Bluetooth prompt without instantiating CoreBluetooth for a feature that does not exist in v1, which is a guaranteed App Review rejection (src/composables/usePermissions.ts:6-11).
  • The Location permission exists only to protect card payments once payment processing is enabled, and granting it must not start location updates (src/composables/usePermissions.ts:12-14, copy at src/plugins/i18n/locales/en.ts:125-128).

Printer discovery plugin — shipped in the repo, unwired in the app

The app still never prints. Fiscal signatures are displayed on screen, not printed: FiscalData {short_signature?, digital_signature?, verification_url?} (src/types/pos.ts:306-311).

⚠️ The plugin ships, but nothing in the app calls it

Verified against upvendo-pos origin/production (1daa4fd05): @upvendo/capacitor-printer-discovery is a real package.json dependency (package.json:38, file:packages/capacitor-printer-discovery) and a CocoaPods path pod (ios/App/Podfile), and that is the full extent of its integration. A grep of upvendo-pos/src returns zero matches for capacitor-printer-discovery, for printers, and for discovery. No app code imports the plugin, starts a scan, or calls any/pos/printers/* route.

So the register-side printer setup screens that the backend route comments describe (upvendo-backend routes/api/pos.php:63-97 — background scan at boot, staff-level printer list, manager-escalated configure/pair, staff-level test print) do not exist in upvendo-pos at this pin. The server half shipped first; the client half is unbuilt.

Consequence: no shipped client populates the printer inventory. PrinterDiscoveryController::report is mounted twice — device-level under /pos/printers/discovery-report (routes/api/pos.php:99) and again for the kiosk at /printers/discovery-report (routes/api.php:234, whose comment casts the kiosk as a background reporter only) — but upvendo-kiosk origin/production (aa6fe85) has no discovery code at all — grep for discovery-report, printer-discovery, mac_source or mDNS service types over its src/ and packages/ returns nothing. It is not printer-free: it drives an integrated thermal printer through its own Capacitor plugin (src/plugins/masung_printer.ts:12, registerPlugin<MasungPrinterPlugin>('MasungPrinter'), used from src/stores/transaction.ts). That plugin prints to attached hardware; it does not enumerate networked printers or report them. So the GET /pos/printers list a setup screen would read has nothing feeding it from any first-party app.

"The POS discovers printers on the LAN" is therefore false today — it is a capability the repo is staged for, not one it performs. Treat it as true only once a src/ import of the plugin exists.

The plugin itself is discovery-only over OS-native mDNS/DNS-SD (iOS NetService, Android NsdManager).

  • API: startDiscovery, stopDiscovery, getDiscoveredPrinters, plus three events. There are deliberately no print, connect or configuration-write methods.
  • There is still no Epson SDK — its EULA bars redistribution.
  • Default browsed service types: _ipp._tcp and _pdl-datastream._tcp (src/definitions.ts:17). Ports: 631 for _ipp, 9100 for _pdl-datastream.
  • Consumed as an npm file: dependency and a CocoaPods path pod.

The Info.plist key is NSBonjourServices — never NSBonjourServiceTypes. The wrong spelling fails at runtime with NSNetServices error -72008, not at build time, so it looks like a discovery bug rather than a manifest typo.

The MAC address is derived from the _ipp record's UUID, so null is a legitimate value — a _pdl-datastream._tcp record carries no UUID. There is no paperWidthMm field.

The Info.plist "TODO: verify against the Epson ePOS SDK" note is resolved: hardware-verified 2026-07-26 against an Epson TM-m30III.


Types and enums

All from src/types/pos.ts on origin/production.

ConceptValuesCite
Order state'OPEN' | 'SETTLED' (declared with a trailing | string for forward tolerance). There is no FIRED state — the backend never emits one; fired-ness is the resource's fired boolean plus a fired-lines hash.:297, :402-406
Till moderegister_drawer | staff_wallet:553
Till stateopen | ended | closed — OPEN takes cash, ENDED has stopped taking cash but is still countable/closable, CLOSED is declared and frozen:573
Drawer kindphysical | virtualsrc/utils/tillState.ts:47
TillDrawer{id, name, kind?} — the frozen fiscal identity: render it verbatim, never re-derive itsrc/types/pos.ts
DrawerCandidateDrawer offered during the open-till 409 resolution flowsrc/types/pos.ts

Till-state normalisation is deliberately asymmetric. The pre-BE2 spelling closing folds into ended via normalizeTillState (src/utils/tillState.ts:30), and an unknown or absent state resolves to open on purpose: reading an OPEN till as ended bricks the register mid-shift, whereas the reverse merely earns a server 409. Nothing compares till.state directly — always go through the normaliser. | Till movement | paid_in, paid_out, drawer_open_no_sale, float_issue, float_return | :555-560 | | Refund mode | full | partial | :655 | | Line discount | {type: 'pct' \| 'amount', value}per-UNIT semantics; pct is whole percent 1-100, amount is minor units per unit | :313-317 | | Money | Integer minor units (cents) everywhere: item.price, unit_price, totals.*, amount_tendered | :137-138 (CatalogItem.price), :299-304 (OrderTotals), :329 (unit_price), :351 (amount_tendered); CLAUDE.md:361-365 |

Exception worth knowing: report money in the X/Z reports is in major units, not minor — the POS repo's CLAUDE.md:320-322 pins this to the backend's Money::…->decimal() serialization and warns against formatPrice (which divides by 100) on report values.

OrderState also encounters 'VOIDED' at runtime: removing the last line from an unfired draft causes the backend to void it and return a bare resource with state: 'VOIDED', which the store treats as success and turns into a fresh order plus a non-error hint (src/stores/order.ts:305-311).


Permissions are hints; the server is the enforcement point

staffStore.hasPermission(name) returns true when the backend sent no pos_permissions list at all — it is deliberately permissive (src/stores/staff.ts:52-60). It exists to hide UI affordances, not to authorize anything. The server 403 is the enforcement point, and a 403 triggers the manager-escalation flow regardless of the hint.

Escalation tokens are transient by design (src/stores/escalation.ts:4-12):

  • memory only — never written to localStorage or sessionStorage (:14-17)
  • scoped to exactly one permission, and consume(permission) returns the token at most once, clearing it on read; a permission mismatch returns null and leaves the token intact (:29-36)
  • the server additionally enforces a 120 s TTL, single permission, and device binding
  • convention: gated requests carry approval_token in the request body, not a header (src/types/pos.ts:344)

The frozen API contract

Every endpoint the client calls, from src/api/services/pos-services.ts (plus the one device endpoint). All of these routes exist on upvendo-backend origin/production (routes/api/pos.php, required from routes/api.php:239), and the server contract matches the client's expectations as documented below — verified for the order-state enum, the counted field, the bare-till + 204 shape and the absence of drawer_descriptor.

The client calls only a subset of the /pos group. Server routes it never touches: POST /pos/orders/{clientUuid}/void (routes/api/pos.php:61), POST /pos/tills/{tillId}/end (:43 — the ENDED transition the till-state section describes), GET /pos/kds-stations (:115) and the /pos/printers/* onboarding routes (:98-108) — the printer group is not merely untouched but entirely unbuilt client-side, see Printer discovery plugin. (These four line numbers re-checked at upvendo-backend origin/production e3727c428; the :115 and :98-108 citations replace stale ones.)

MethodPathPurposeCite
POST/device-auth/activatedevice activation (shared with kiosk)device-services.ts:8-12
POST/pos/staff/pin-loginstaff sign-in → {staff_token, staff}:18-20
POST/pos/staff/pin-logoutbest-effort FDM work-out event, 2 s timeout, all failures swallowed:22-36
GET/pos/bootstrapdevice + location + kiosk-style menu (display_groups[]):38-40
PUT/pos/orders/{clientUuid}full-document order upsert:42-47
GET/pos/orders/{clientUuid}refetch server truth:49-51
GET/pos/orders?state=order list → {data, total} envelope. state is required, one of open|settling|settled; page/per_page attached only when supplied:57-65
GET/pos/channel-ordersomni-channel orders. Params view = active|scheduled|completed, plus optional channel, dining_option, page, per_page. Returns {data, total, fulfillment, channels}fulfillment and channels are always JSON objects ({} when empty, ksorted, zero-count channels absent)pos-services.ts
GET/pos/channel-orders/{id}order detail, stream-eligibility-gated server-side: foreign-location, unpaid, cancelled and >24h-complete all return the SAME uniform 404pos-services.ts
POST/pos/orders/{clientUuid}/firefire to kitchen → bare resource with fired: true:67-69
POST/pos/orders/{clientUuid}/settlecash settle {amount_tendered} → bare resource:71-78
POST/pos/tillsopen till {mode, opening_float, drawer_id?}see sharp edge 3:82-87
GET/pos/tills/currentcurrent open till — see sharp edge below:94-102
POST/pos/tills/{id}/movements{type, amount, reason?}; amount is a positive int in cents, the server signs by direction:104-109
POST/pos/tills/{id}/countblind count — see sharp edge below:111-117
POST/pos/tills/{id}/close{approval_token?}:119-124
POST/pos/staff/escalate{manager_pin, permission}{approval_token}:126-134
POST/pos/refunds{transaction_id, mode, line_uuids?, approval_token?}:136-143
GET/pos/business-dayday peek:145-147
POST/pos/business-day/close{approval_token?}:149-156
GET/pos/business-day/x-reportnested {turnover_x, user_x} payload:158-160

Three sharp edges

1. GET /pos/tills/current expects a BARE resource and rejects an envelope.

ts
const till = response as Record<string, unknown>
if (typeof till.id !== 'string' || typeof till.mode !== 'string') return null

A wrapped {till: {...}} response fails the id/mode shape check and resolves to null, which the router reads as "no open till" and bounces the operator to /till. The comment records why: the old permissive cast produced a garbage till whose subsequent operations hit /pos/tills/undefined/… (src/api/services/pos-services.ts:89-102). A 204 or null body is the correct "no till" response.

Server-verified: PosTillController::current() returns a bare TillResource and 204 No Content when there is no open till (app/Http/Controllers/Api/Pos/PosTillController.php:228-234) — the same bare shape as POST /pos/tills. Note the count and close responses are not bare: they nest the till under a till key alongside the flat variance block (:145-153, :203-206).

2. POST /pos/tills/{id}/count sends the field counted.

ts
return (await fetchInstance.post(`/pos/tills/${tillId}/count`, { counted })) as TillCountResult

Not counted_close — the comment states that is only a temporary server-side alias and must never be sent (src/api/services/pos-services.ts:111-117). The count/close responses carry top-level expected, counted, variance, variance_level, requires_approval (src/types/pos.ts:641-648). The UI is a blind count: expected is never shown before the operator enters their number (src/plugins/i18n/locales/en.ts:245).

Server-verified: counted is the canonical rule (app/Http/Requests/Pos/TillCountRequest.php:36, 'counted' => 'required|integer|min:0'), and counted_close is merged in with a logged deprecation warning when counted is absent (:18-27). The alias is a wire concern only — the persisted field is still counted_close, which TillService::varianceMeta() reads back out as counted (app/Services/Pos/TillService.php:417). The count response is {till, expected, counted, variance, variance_level, requires_approval, approval_permission} (PosTillController.php:145-153).

3. POST /pos/tills must never send drawer_descriptor.

Send {mode, opening_float}, and add drawer_id only on the 409 retry — the server answers a conflicting open-till attempt with the candidate drawers, and the client re-submits naming the chosen one. drawer_descriptor is not part of the contract; the drawer identity that comes back (TillDrawer {id, name, kind?}) is frozen fiscal data to be rendered verbatim, never re-derived.

Server-verified: OpenTillRequest accepts exactly {mode, opening_float?, drawer_id?, approval_token?} and has no drawer_descriptor (app/Http/Requests/Pos/OpenTillRequest.php:24-33, with the docblock at :15-18 recording that the signed drawer identity is derived server-side from PosDrawer.fiscal_ref).

Request budgets

The client pins explicit per-call timeouts rather than relying on a global default. Note the Chromium-95 constraint: timeoutSignal must be constructed defensively because AbortSignal.timeout() is not available on the older embedded webview, so the helper falls back to a manual setTimeout + AbortController.


Known gaps and open risks

Proxy CORS does not allow X-Pos-Staff-Token

Verified in code, not just flagged. The proxy's preflight response builder sets a static Access-Control-Allow-Headers list:

text
Content-Type, Authorization, X-API-Key, X-Capacitor-API-Key,
X-Requested-With, Ngrok-Skip-Browser-Warning, X-Order-Number

upvendo-backend-proxy origin/production src/utils/response-helpers.js:184-200 (default at :187), reached from src/cors-helpers.js:11-14. A grep for X-Pos-Staff-Token across the proxy's src/ returns zero matches.

Every authenticated POS call after PIN login carries X-Pos-Staff-Token (fetchInstance.ts:88-91), so those requests would fail preflight from a cross-origin context until the header is added. The POS repo's own CLAUDE.md:322-325 records this as an open item ("Faizal to add it").

Origins are not the problem: ALLOWED_ORIGINS on the production proxy already includes https://pos.upvendo.com (wrangler.toml:54), and getAllowedOrigin() additionally short-circuits any origin whose hostname is localhost — which covers the native WebView's capacitor://localhost (src/auth-helpers.js:9-24). Only the header allowlist is missing.

Not verifiable from these repositories

QuestionWhy it cannot be answered here
Is the iOS app deployed to any device?The repo states it is not (CLAUDE.md:275-282,333-337), but distribution state lives in App Store Connect / Capgo, not in git.

Server behaviour is no longer in this category. POST /pos/orders/{uuid}/fire resolves to PosOrderController::fire(), which delegates to PosOrderOrchestrator::fire() with the staff claims, the device's location id and the device id (app/Http/Controllers/Api/Pos/PosOrderController.php:130-141, routed at routes/api/pos.php:59). The 409 version-conflict body is shaped by PosOrderController::versionMismatch() and carries {message, current_version} at the exception's own HTTP status (:104-105, :218-224).

Recorded follow-ups in the POS repo

From CLAUDE.md:356-369, so nobody documents them as shipped: no in-dialog numeric keypad for tender entry, no focus trap in dialogs, no Keychain storage for the device bearer token, no staff idle auto-lock, and Register.vue uses h-screen without a .safe-area wrapper.


Debugging pointers

  1. Everything 404s — check the baked base URL still ends in /api. The app appends /pos/* with no /api of its own (fetchInstance.ts:49-50,150).
  2. Preflight failures after PIN login — see the CORS gap above; the request that breaks is the first one carrying X-Pos-Staff-Token.
  3. Operator bounced to /activate unexpectedly — the 401 heuristic cleared the device because the failing request had no staff token (fetchInstance.ts:103-115). Look for a 401 on a staff-token-less call such as pin-login or activate.
  4. Register keeps redirecting to /tillGET /pos/tills/current returned something the bare-resource check rejected (wrapped envelope, missing id/mode), so the store reports no open till (pos-services.ts:94-102, router/index.ts:119-130).
  5. Totals look wrong — they are never computed locally. Whatever is on screen came from the server response (src/stores/order.ts:106-107). Check report values are not being run through formatPrice (major vs minor units, CLAUDE.md:320-322).
  6. A stale build keeps coming back after a native install — a Capgo channel bundle silently overrides the native build's JS. Push the channel matching the device (CLAUDE.md:304-310).