Skip to main content

Delivery orders and routing (feature 064)

Feature 064 lets restaurant staff and call-center agents attach a customer, a delivery address, a phone and an optional billing profile to an internal delivery order. At submit, the order keeps an immutable snapshot of who and where. Receipts, delivery tickets, reprints, the default bill buyer and FEL read that snapshot, never the live customer, so a later customer edit never changes a past order.

The design record is in specs/064-delivery-order-snapshot/: spec.md (FR-…, SC-…), research.md (decisions D1–D18, residuals R1–R9), data-model.md, contracts/delivery-order-api.md and quickstart.md (the plant table and its results).

Origin and fulfilling location​

Every order has two locations:

ColumnMeaning
order.location_idThe fulfilling location: where the order is cooked, delivered, charged and invoiced. Unchanged meaning; every existing report, price lookup, shift and cash session keys on it.
order.origin_location_idThe origin: where the order was captured. Nullable, no backfill. NULL reads as location_id — the repository's withOrigin normalizes every row it returns.

An order is routed when origin_location_id ≠ location_id. Only delivery_internal orders can be routed (D18): OrdersService.createOrder refuses anything else with ROUTING_REQUIRES_DELIVERY, and once an order is routed or has delivery details its type is locked (ORDER_TYPE_LOCKED).

Routing configuration​

location_order_routing (one row per location, absent ≡ not a call center) and location_routed_branch (its served branches) are edited through GET/PUT /businesses/:businessId/locations/:locationId/order-routing, with If-Match versioning and the OrderRouting permission (owner, admin, administrator).

  • A call center never fulfils: it must name a branch from its served list (FULFILLING_LOCATION_REQUIRED).
  • Any location may route to a branch it serves when the caller holds RemoteOrder Create (owner, admin, administrator, store manager, customer-service representative — not cashier, not IT support through its cashier bundle).
  • The served list is checked when the branch is chosen and again at submit (FULFILLING_LOCATION_NOT_SERVED, FULFILLING_LOCATION_CLOSED when the branch requires an open shift and has none).

The branch is chosen after the address and before the items, so menu, prices and availability come from it. PUT /orders/:id/fulfilling-location changes it on a draft only: it clears the cart (the caller must acknowledge it), re-numbers the order and records the change. After submit the branch is locked by every path — the route gives FULFILLING_LOCATION_LOCKED (and records the refusal), and a general PATCH /orders/:id {locationId} on a delivery or routed order gives FULFILLING_LOCATION_USE_ROUTE.

The snapshot and its trigger​

order_delivery is 1:1 with the order. Before submit it holds the attachment (ids of the customer's elements plus order-only overrides) and is versioned (If-Match). On the draft → submitted transition, inside the submit transaction, DeliverySnapshotService.assertSubmittableAndCapture locks the order, checks completeness, the served branch, the pay-with amount and the buyer-ID threshold, then writes snapshot (a DeliverySnapshotV1 JSONB) and snapshot_at.

The database enforces immutability:

  • order_delivery_snapshot_guard (BEFORE UPDATE) raises P0001 "order_delivery snapshot is immutable" for any update after snapshot_at is set, except the single anonymization transition (anonymized_at NULL → set, every other column unchanged).
  • order_delivery_snapshot_no_delete refuses deleting a submitted row (and so its order).

Address lines are rendered with the origin location's address form (063), and the form version is frozen with them.

Tickets and receipts​

  • The delivery ticket (document_print_job kind delivery_ticket) is queued by OnDeliveryOrderSubmittedEvent, emitted after the submit transaction commits: order and kitchen events fire inside the transaction, so a handler there would read uncommitted state. A missing printer is recorded, never thrown.
  • Order and bill receipts print a "Domicilio" block (customer, phone, address lines, instructions, payment) and, for routed orders, an origin line. Kitchen tickets get a DOMICILIO marker and the origin.
  • There are two renderers — the Handlebars thermal templates (what devices print; synced by migration) and packages/receipt-layout (bridge fallback and previews). Change both; delivery-receipt-parity.spec.ts compares them. Long values wrap, never truncate, at 32 and 42 columns.

FEL: establishment and buyer identification​

  • Invoices use the fulfilling branch's establishment (location.tax_number), frozen on the bill at certification. A cancellation voids the document that branch certified.
  • New bills of a delivery order start from the snapshot's billing identity; a different buyer on the bill is written and recorded in the activity log, and the snapshot is untouched.
  • Buyer ID above Q2,500: SAT requires the buyer's NIT or CUI on invoices strictly greater than Q2,500 (Acuerdo Gubernativo 245-2022). The constant is FEL_BUYER_ID_THRESHOLD_GTQ with requiresBuyerId() in packages/global/consts/fel-buyer-id.consts.ts; amounts in another currency are converted with the order's rate, and a missing rate counts as above. FelBuyerIdPolicy applies it, only when the invoice will be issued through FEL:
    • at submit, a delivery order without a billing profile;
    • before the payment insert that settles a bill (tip included) — on both restaurant FEL paths: bills (order-bill-payment.service) and the single-payment path (collect-payment, certified through the sale on-order-settled.handler writes). Earlier partial payments stand;
    • on fel/retry, and as a last line inside certification (which never throws: it marks the bill FAILED with FEL_BUYER_ID_REQUIRED). A paid bill whose certification failed may have its buyer corrected and be retried.

Origin access and cancellation​

On the existing /orders/:id… routes, RoutedOrderAccessGuard lets a routed order be changed only by callers assigned to its origin or fulfilling location. After submit, a caller assigned only to the origin may do one thing: cancel, through PATCH /orders/:id with { status: "cancelled", cancellationReason }. The controller hands this to OriginCancellationService, which requires RemoteOrder Create and a 3–200 character reason, then in one transaction cancels the order, voids its items with the reason and marks its pending kitchen tickets voided; after commit each station receives ticket:voided and the branch sees order.updated. GET /orders?originLocationId= lists what a location captured, with an explicit assignment check (the global LocationAccessGuard reads only locationId).

Anonymization​

Snapshots follow the order's retention. When 062's customer anonymization job anonymizes a customer, it calls the registered CustomerAnonymizationParticipants inside its transaction; the restaurant module registers DeliverySnapshotAnonymizationParticipant, which clears the contact fields of that customer's completed-order snapshots and keeps the fiscal fields. The customers module never touches restaurant tables. A customer with an open delivery order is held back; only orders updated in the last 7 days count as open, and customers held back longer than that are logged.

Residuals​

#Residual
R1Order :id routes have no tenant or location authorization for non-routed orders: any signed-in user can read or change any order by id. 064 guards its own routes and routed orders only.
R2PATCH /orders/:id still changes locationId for dine-in and take-out orders at any status, without re-pricing. Closed for delivery and routed orders.
R3A general order cancellation voids no kitchen tickets, and split-bill settlement never emits order.settled (no inventory deduction). Only 064's origin cancellation voids tickets.
R4The 86 flag is per product, not per location.
R5Retail sales do not enforce the buyer-ID threshold.
R6fel_invoice print jobs have no thermal template; the restaurant FEL printout is the bill receipt.
R7Marketplace (third-party delivery) invoices do not enforce the buyer-ID threshold.
R8Item voids elsewhere do not reach the kitchen display, and paper kitchen tickets get no void slip, even for 064's cancellation.
R9Splitting one order into several bills, each under Q2,500, avoids the buyer-ID rule; nothing detects it (SAT evaluates each invoice).

Tests worth knowing​

Integration specs live in apps/backend/src/restaurant/__tests__/ and use requireTestDatabase() (they fail without a database). Every gate was planted and seen to fail; the results table is in quickstart.md § 3. Builders in test/helpers/delivery-test-helpers.ts (buildDeliveryServices, buildBillServices, enableBillFel) wire the real services over the test database.