02 — Invoice sync (POST /v1/invoices/sync)
Create or update invoices (orders). Assigns order_no and customer_invoice_id, then queues persistence.
Endpoint
POST /v1/invoices/sync
Authorization: Bearer <jwt>
Content-Type: application/json
Body: JSON array of invoice objects (not a wrapped object).
Validation: src/validations/invoice.validation.js → syncInvoice
Handler: invoiceController.syncInvoice → invoiceService.syncInvoice
To cancel, use PUT /v1/invoices/:uuid/cancel — do not rely on sync alone for cancel side effects (inventory / wallet). See 03-INVOICE-READ-CANCEL.md.
Required fields checklist
Per invoice
| Field | Type |
|---|---|
uuid |
string (client-generated; stable for the order lifetime) |
request_id |
string (trimmed, min length 1) — idempotency key; never leave empty; server never invents one |
status |
"open" | "cancelled" | "completed" |
customer_id |
number |
location_id |
number |
user |
string |
created_at |
number (Unix seconds) |
updated_at |
number (Unix seconds) |
subtotal |
number |
vat |
number |
payable |
number |
items |
array |
order_type |
string (e.g. "dine-in", "takeaway") |
Per line item (items[])
| Field | Type |
|---|---|
name |
string |
product_id |
number |
qty |
number |
actual_price |
number |
invoice_uuid |
string (must match parent uuid) |
vat_pct |
number |
customer_id |
number |
location_id |
number |
Full optional catalog: 06-PAYLOAD-REFERENCE.md.
Success response
HTTP 200
{
"status": true,
"code": 200,
"message": "Invoice numbers synchronized successfully",
"data": [ /* one object per input invoice */ ],
"trace_id": "<server-trace-id>"
}
What is added/overwritten on each data[] element
| Field | Type | Meaning |
|---|---|---|
order_no |
number | Business-day order number (authoritative; may overwrite client) |
customer_invoice_id |
number | Invoice number for this tenant/location flow |
cached |
boolean | true if numbers came from Redis / prior resolution; false if newly generated this pass |
On idempotent replay of the same request_id, also expect:
| Field | Type | Meaning |
|---|---|---|
request_id_status |
0 | 1 | 2 |
Progress of original request (see below) |
error_code |
string | Often "DUPLICATE_REQUEST" |
request_id_status
| Value | Meaning |
|---|---|
0 |
Request accepted earlier; numbers not yet assigned |
1 |
Numbers assigned (worker may still be running) |
2 |
Inventory handoff stage reached for completed invoices |
Keep customer_invoice_id / order_no from the response and treat the call as successful (do not create a second UUID).
Critical client behaviour: async persistence
After 200:
- Numbers are resolved and cached (Redis, ~24h TTL per request_id / uuid).
- Jobs are enqueued (BullMQ
order-processing); HTTP does not wait for DB write. - Worker later: validate → refactor → upsert invoice + items + payments.
Implications for mobile:
- Show
order_no/customer_invoice_idimmediately fromdata. - If UI needs confirmed DB state, re-fetch
GET /historyor KDS/dine shortly after. - On network retry with the same
request_id, expect duplicate-safe response (same numbers), not a second order.
Idempotency rules (request_id)
- Generate a unique
request_idper user intention (new sync attempt). Retries of the same attempt must reuse the samerequest_id. - Same
request_idin one HTTP batch: later duplicates reuse the first’s numbers and are not queued again (skipQueuestripped from response). - Cross-request: Redis key
inv_req_id:{request_id}(24h). Service does not delete it; it expires. - Per-UUID Redis
inv-uuid:{uuid}also caches numbers for 24h.
Do not change uuid across retries if you already received numbers for that request_id.
Nested structures to send correctly
Payments
"payments": [
{ "payment_method": "cash", "amount": 50, "tip_amount": 0 },
{ "payment_method": "card", "amount": 55, "tip_amount": 0 }
]
Addons (nest under parent)
"items": [
{
"name": "Burger",
"product_id": 42,
"qty": 1,
"actual_price": 25,
"invoice_uuid": "<same-as-parent-uuid>",
"vat_pct": 5,
"customer_id": 1,
"location_id": 2,
"linked_uuid": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"addons": [
{
"name": "Extra Cheese",
"product_id": 99,
"qty": 1,
"actual_price": 2,
"invoice_uuid": "<same>",
"vat_pct": 5,
"customer_id": 1,
"location_id": 2
}
],
"modifiers": [
{
"quantity": 1,
"modifier_id": 3930,
"actual_price": 17,
"subtotal": 17,
"name": "Fries + Fanta"
}
]
}
]
On read APIs, addons are nested again; modifiers return as modifiers arrays.
Notes
"note": "Kitchen: allergy",
"notes": ["No onions", "Extra spicy"]
| Field | Who sends | Storage |
|---|---|---|
note |
web-pos, iOS | invoices.note (string) |
notes |
iOS (array of strings) | invoices.order_notes (JSON) |
notes is returned as a string array on history/KDS/dine. web-pos currently sends only note; map orderNote → notes: [orderNote] when you adopt the array.
Full attribute lists: 06-PAYLOAD-REFERENCE.md.
Annotated minimal example
[
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"request_id": "ios-2026-07-08-op-001",
"status": "open",
"customer_id": 437,
"location_id": 907,
"user": "[email protected]",
"user_id": 10,
"created_at": 1720411200,
"updated_at": 1720411205,
"subtotal": 100,
"vat": 5,
"payable": 105,
"order_type": "dine-in",
"table_id": 5935,
"is_syspos": 1,
"is_paid": 0,
"payments": null,
"note": "Table 5",
"notes": ["No onions"],
"os_type": "ios",
"app_version": "2.0.0",
"items": [
{
"name": "Burger",
"product_id": 42,
"qty": 2,
"actual_price": 50,
"price": 50,
"subtotal": 100,
"invoice_uuid": "550e8400-e29b-41d4-a716-446655440000",
"vat_pct": 5,
"customer_id": 437,
"location_id": 907,
"linked_uuid": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"is_addon": 0,
"addons": null,
"modifiers": []
}
]
}
]
Completing / paying an order via sync
Typical pattern:
- First sync:
status: "open",is_paid: 0,payments: null - Later sync same
uuid, newrequest_id(new operation):status: "completed",is_paid: 1,paid_at,payments: [...]
Reuse the same invoice uuid so numbers stay tied to that order. Inventory side effects for completed orders are handled server-side after completion (not for abandoned opens).
Errors
| HTTP | Cause |
|---|---|
400 |
Joi validation (missing required fields, bad enum, empty request_id, etc.) |
400 |
Service: UUID missing / request_id missing |
401 |
Auth |
Validation runs before JWT on this route (validate then jwtAuth).
Client algorithm (suggested)
1. Build invoice DTO locally with new uuid (or existing open uuid)
2. Generate request_id for this sync attempt; persist it until success
3. POST /v1/invoices/sync with [invoice]
4. On 200:
- Save order_no, customer_invoice_id from data[i]
- If error_code == DUPLICATE_REQUEST → treat as success with returned numbers
5. On timeout/network error → retry SAME request_id + SAME uuid (do not mint new uuid)
6. Optionally refresh from GET /history or dine/kds
Related
- Field catalog: 06-PAYLOAD-REFERENCE.md
- Response read shape for later: 03, 04, 05