Skip to content

Merchant ↔ consumer interop

The merchant dashboard and the consumer wallet never talk to each other. Every shared feature is a pair of apps/api endpoints operating on the same PostgreSQL rows, so "does the merchant see the customer's order" is a question about two endpoints agreeing on a tenant key, a status value, and a payload shape.

This page records the seams, how each was verified, and the two that behave differently from how they read.

The seams

Seam Merchant writes Consumer reads
Catalog POST /products GET /menu/merchant/:id, /menu/scan, /menu/table/:id
Orders GET /orders POST /orders/customer/checkout
Order chat GET,POST /orders/:id/messages GET,POST /orders/customer/:id/messages
Fulfilment PUT /orders/:id (status) GET /orders/customer/:id
Ready alert status change → ready_notification_outbox email + push (not the in-app feed)
Settlement POST /orders/:id/complete-cash
Ledger GET /dashboard/transactions
History GET /orders/customer/me/history
Payment request POST /dashboard/payment-requests GET /payments/:id
Loyalty POST /loyalty/programs GET /loyalty/user/:id/programs
Customers GET /customers created implicitly by ordering

All of the above were exercised end to end against a local API and PostgreSQL on 2026-08-07, driving each side with its own Better Auth JWT.

Tenant key

orders and their children key on the uuid tenant key, never on whatever identifier the client sent. POST /orders/customer/checkout accepts either a uuid or a privy_id for merchant_id and normalises to merchants.id before writing. Merchant-side reads scope on c.get("merchantId"), which is the same uuid.

A consumer identity is different: POST /auth/create-customer stores the Better Auth subject in customers.privy_id, and leaves auth_user_id null. That is deliberate — see the merchant identity notes in the API docs before "fixing" it.

Order state machine

orderService.updateOrder enforces the transitions. A Sagio-Pay checkout lands in awaiting_payment; every later transition additionally requires payment_status = 'paid':

awaiting_payment ──(settlement)──▶ pending ──▶ processing ──▶ ready | ready_for_pickup ──▶ completed

POST /orders/:id/complete-cash is a separate path that jumps straight to completed. It requires an Idempotency-Key header, not a body field.

Two consequences that surprise people:

  • Order chat is closed while an order is unpaid. orderMessageService allows messages only in pending, processing, ready, ready_for_pickup. A customer sitting in awaiting_payment cannot ask a question, and the merchant cannot reach them. Sending anyway returns 409.
  • A ready transition from awaiting_payment is refused. This is correct, not a bug; the order has not been paid for.

"Your order is ready" does not use the in-app feed

This is the most misleading part of the system.

GET /orders/customer/me/notifications reads order_notifications. The only writer of that table is the ready-reversal path, and the wallet's order-notification-listener.tsx only reacts to type === "ready_reversed". There is no ready_for_pickup row and there never was.

The actual delivery path is:

PUT /orders/:id {status: ready_for_pickup}
  └─ trigger schedule_ready_notification()
       └─ INSERT ready_notification_outbox
            └─ Worker cron (*/1 * * * *) drains it → email + PUSH_QUEUE

So the contract the merchant side owes the consumer is an outbox row, not a feed entry. Assert on ready_notification_outbox when testing this seam.

The trigger was broken from the uuid migration until 2026-08-07

schedule_ready_notification() was written when orders.merchant_id held the varchar privy_id. migrateMerchantUuidTenantKey retyped the column to uuid and left the trigger's predicates alone:

WHERE merchant.privy_id = NEW.merchant_id   -- varchar = uuid
   OR merchant.id::text = NEW.merchant_id   -- text    = uuid

Both raise 42883. Because the failure came from a trigger it aborted the whole UPDATE, so marking an order ready returned 500 and no customer was ever notified. Production carried the broken trigger with zero orders ever having reached ready_for_pickup to show for it.

The lesson generalises: database triggers are not covered by unit tests and were not audited when the tenant key changed type. When changing a column type, search pg_proc for functions referencing it:

SELECT proname FROM pg_proc WHERE prosrc LIKE '%NEW.merchant_id%';

Testing this locally

The API verifies a Better Auth ES256 JWT against the local JWKS, and there is no email/password path (Google only). To drive both sides:

  1. Set BETTER_AUTH_SECRET; the API mints its own JWKS on first use.
  2. Insert a user row and a session row directly.
  3. Exchange the raw session token for the JWT the middleware wants: GET /api/auth/token with Authorization: Bearer <session.token> (the bearer plugin accepts the session token; authMiddleware requires the JWT).
  4. A merchant identity also needs a merchants row linked by auth_user_id or email, with a smart_wallet_address — Sagio-Pay checkout returns 400 without one.
  5. A consumer identity needs POST /auth/create-customer.

/api/health reports BETTER_AUTH_URL missing whenever the base URL is http://localhost:8787. That is cosmetic; auth works.

The database must first be reconciled — see Database schema of record.