Invoice history — list response (GET …/history)
Returns a paginated list of invoices already persisted in the database. Each element in data.orders uses the same per-invoice object shape and datatypes as the POST …/sync request body defined in src/validations/invoice.validation.js:
- Addon lines (
is_addon = 1in DB) are nested under the parent item’saddonsarray. - Modifiers are returned as
modifiers: one flat array of modifier items (no nesteditemsarray). paymentsare rebuilt from thepaymentstable (not the legacypayment_methodcolumn).- Timestamps are Unix seconds (numbers); boolean flags are
0/1. extrason line items is returned as a JSON string ("{\"extraId\": qty}") when present; omitted when empty (history only).- DB-only fields (invoice
id, itemid, internal columns) are omitted.
Authentication: jwtAuth — send a valid JWT in the Authorization header. Tenant scope (customer_id, location_id) is taken from the token, not from query parameters.
Route: GET /v1/invoices/history
Implementation: src/controllers/invoice.controller.js → invoiceService.getInvoices → InvoiceRepository.getInvoicesWithFilters → mapInvoiceToSyncPayloadShape.
See also: INVOICE_REQUEST.md for the full sync payload field reference.
Authentication
Authorization: Bearer <jwt>
The JWT payload is attached to req.user. The service requires:
| Claim | Aliases accepted | Required |
|---|---|---|
customer_id |
customerId |
yes |
location_id |
locationId |
yes |
If either is missing, the API responds with 400 and message: Authentication must include customer_id and location_id.
All queries are automatically scoped to that customer_id + location_id. Sending different tenant ids in query params has no effect.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
status |
string | — | Filter by invoice status: "open", "cancelled", or "completed" |
startDate |
string | — | Inclusive lower bound on invoice_date (repository Op.gte) |
endDate |
string | — | Inclusive upper bound on invoice_date (repository Op.lte) |
search |
string | — | Accepted by the controller but not applied in getInvoicesWithFilters today |
page |
number | 1 |
Page number (1-based) |
limit |
number | 10 |
Page size |
sortBy |
string | created_at |
Column to sort by |
sortOrder |
string | DESC |
ASC or DESC |
Status values
| Value | Meaning |
|---|---|
open |
Order in progress |
cancelled |
Cancelled order |
completed |
Completed / paid order |
Matches INVOICE_STATUS in src/enums/invoice.enum.js.
Example request
GET /v1/invoices/history?status=completed&page=1&limit=20&sortBy=created_at&sortOrder=DESC
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Response envelope
The handler wraps results in a standard success envelope:
{
"status": true,
"message": "Orders history",
"data": {
"current_page": "1",
"records_per_page": 10,
"total_records": 42,
"orders": [ /* invoice objects — see below */ ]
}
}
| Field | Type | Description |
|---|---|---|
status |
boolean | Always true on success |
message |
string | "Orders history" |
data.current_page |
string | Current page (1-based), stringified |
data.records_per_page |
number | Page size |
data.total_records |
number | Total matching rows |
data.orders |
array | Invoice objects in sync payload shape |
Invoice object shape (data.orders[])
Each invoice is mapped by mapInvoiceToSyncPayloadShape (src/utils/invoice-sync-response-mapper.js) so it mirrors the per-invoice element of the sync request schema — same field names, ordering, and datatypes.
Full attribute catalog (including newer columns such as row_discount, crm_delivery_area, rider_arrived_at, item item_status / transfer lists / disposition / cost snapshot fields): see frontend-handout/06-PAYLOAD-REFERENCE.md and INVOICE_REQUEST.md.
Fields included from persistence
| Field | Type | Notes |
|---|---|---|
customer_invoice_id |
number | Coerced from DB |
order_no |
number | Order number for the business day |
items |
array | Parent line items only; addons nested (see below) |
payments |
array | null |
From payments table; null when none |
Fields not returned (or absent from DB)
| Field | Notes |
|---|---|
request_id |
Client idempotency key on sync; not stored on the invoice row |
cached |
Sync-only metadata; not returned on history |
skipQueue |
Internal sync flag |
request_id_status |
Idempotent replay metadata from sync only |
error_code |
Idempotent replay metadata from sync only |
id (invoice / item) |
DB primary keys omitted |
Columns transformed on read
| DB source | Response field | Transformation |
|---|---|---|
payments table rows |
payments |
[{ payment_method, tip_amount, amount }, …] ordered by user slot |
modified_by_user_ids (TEXT) |
modified_by_user_ids |
Parsed to number[]; defaults to [] |
order_notes (JSON / TEXT) |
notes |
Parsed string array (legacy iOS key); order_notes is not returned |
modifier (TEXT, per item) |
modifiers |
JSON string → flat array (see Modifiers below) |
extras (VARCHAR, per item) |
extras |
Normalized map → JSON string; omitted when empty |
updated_by_user_ids (TEXT, per item) |
updated_by_user_ids |
Parsed to number[] |
created_at, updated_at, paid_at, … |
same keys | MySQL/Sequelize dates → Unix seconds (number) |
| Boolean / flag columns | same keys | Coerced to 0 / 1 |
Full field catalog
For invoice-level and line-item fields (finance, flags, CRM, loyalty, etc.), use the same schema as sync — documented in INVOICE_REQUEST.md and enforced in src/validations/invoice.validation.js.
Fields not persisted in MySQL (is_qr_payment, qr_payment_link, is_loyalty_enable, is_sms_enable) are returned with Joi defaults (0 or ""). combo_items is always null (not stored separately).
Line items (items[])
Parent items only
The flat invoice_items rows where is_addon = 1 are removed from the top-level items array. Only parent product lines appear there.
Items are loaded with the invoice, ordered by sort_order ascending.
Addons nested under parent
On sync, nested addons from the client payload are flattened into separate DB rows (is_addon: 1, addon_linked_with = parent linked_uuid). History reverses that:
- Shape each row (
mapInvoiceItemToSyncShape). - Split into parent rows (
is_addon !== 1) and addon rows (is_addon === 1). - For each parent, attach matching addon rows under
addons, keyed byaddon_linked_with↔ parentlinked_uuid. - Omit
extras,is_addon, andaddon_linked_withfrom nested addon objects.
| Parent field | Value when addons exist |
|---|---|
addons |
array of addon objects, or null if none |
is_addon |
0 |
addon_linked_with |
parent linked_uuid (as stored) |
Modifiers (modifiers)
Modifiers stay on the same line item they belong to (parent product or nested addon). Unlike addons, they are not flattened into separate invoice_items rows.
modifiers is a single flat array of modifier item objects — the same field name and structure the client sends on sync. There is no nested items array.
Typical shape
[
{
"quantity": 1,
"id": 21897,
"subtotal": 17,
"modifier_id": 3930,
"actual_price": 17,
"description": "",
"priority": 1,
"name": "Fries + Fanta",
"comment": "draft"
}
]
Stored in invoice_items.modifier as a JSON string; returned on GET /history as modifiers (array) with all keys preserved.
Write vs read
| Stage | Field name | Type |
|---|---|---|
POST /sync request (Joi) |
modifiers |
flat array | null |
| Persisted in DB | modifier column |
JSON string |
GET /history response |
modifiers |
flat JSON array (parsed) |
On write, refactorInvoice takes item.modifier ?? item.modifiers, serializes to modifier, and removes modifiers. History renames back to modifiers.
Parsing rules
| DB value | Response modifiers |
|---|---|
"[]" or valid JSON array string |
flat array |
Legacy nested groups ({ items: [...] }) |
flattened; nested items keys removed |
null / "" |
null |
Example — line item with modifiers
{
"name": "Combo Meal",
"product_id": 42,
"qty": 1,
"actual_price": 25,
"invoice_uuid": "550e8400-e29b-41d4-a716-446655440000",
"linked_uuid": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"is_addon": 0,
"modifiers": [
{
"quantity": 1,
"id": 21897,
"subtotal": 17,
"modifier_id": 3930,
"actual_price": 17,
"name": "Fries + Fanta"
}
],
"addons": null
}
Differences vs POST /sync
| Aspect | POST /sync |
GET /history |
|---|---|---|
| HTTP body | JSON array of invoices | { status, message, data: { …, orders } } |
| Sync envelope | { status, code, message, data, trace_id } |
{ status, message, data } — no code / trace_id |
| Per-order shape | Sync request payload + customer_invoice_id, order_no, cached |
Same payload shape; no cached |
| Timestamps | Unix seconds (number) | Unix seconds (number) |
| Boolean flags | 0 / 1 |
0 / 1 |
payments |
From request body | From payments table |
| Line-item modifiers | modifiers |
modifiers |
items |
Client sends nested addons |
Server returns nested addons |
request_id |
Required on sync | Not returned (not stored) |
| Persistence | Async worker after HTTP response | Reads committed DB rows |
Full response example
{
"status": true,
"message": "Orders history",
"data": {
"current_page": "1",
"records_per_page": 10,
"total_records": 1,
"orders": [
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"customer_id": 1,
"location_id": 2,
"user_id": 10,
"user": "[email protected]",
"created_at": 1709985600,
"updated_at": 1709985900,
"subtotal": 100,
"vat": 5,
"payable": 105,
"paid": 105,
"order_type": "dine-in",
"customer_invoice_id": 1042,
"order_no": 15,
"payments": [
{
"payment_method": "cash",
"tip_amount": 0,
"amount": 105
}
],
"is_paid": 1,
"is_syspos": 1,
"modified_by_user_ids": [10],
"items": [
{
"name": "Burger",
"product_id": 42,
"qty": 1,
"actual_price": 5.5,
"invoice_uuid": "550e8400-e29b-41d4-a716-446655440000",
"customer_id": 1,
"location_id": 2,
"vat_pct": 5,
"linked_uuid": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"addons": null,
"is_addon": 0,
"addon_linked_with": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"modifiers": [
{
"quantity": 1,
"id": 21897,
"subtotal": 17,
"modifier_id": 3930,
"actual_price": 17,
"name": "Fries + Fanta"
}
]
}
]
}
]
}
}
Errors
| HTTP | When |
|---|---|
| 401 | Missing/invalid/expired JWT |
| 400 | JWT missing customer_id or location_id |
| 500 | Database/query failure (via ApiError from repository) |
What happens on the server
- Route —
GET /historyrunsjwtAuth, theninvoiceController.getInvoices. - Controller — Picks allowed query keys, calls
invoiceService.getInvoices, wraps the result in{ status, message, data: { current_page, records_per_page, total_records, orders } }. - Service — Resolves
customer_id/location_idfrom JWT; queries invoices with items; batch-loads payments by invoice UUID; maps each row throughmapInvoiceToSyncPayloadShape. - Repository — Builds
WHERE(status, tenant, optionalinvoice_daterange);findAndCountAllwithInvoiceItemincluded (as: 'items', ordered bysort_order).
No queue, worker, or Redis involvement on read.
Related endpoint: get by UUID
GET /v1/invoices/:uuid — single invoice, same JWT tenant scope.
Note: This route currently returns the raw repository row without item includes or sync-payload mapping. For a single invoice with the same shape as history, use history with filters or align getInvoiceByUuid with the history mapper in a future change.
Related code
| Piece | Path |
|---|---|
| Route | src/routes/v1/invoice.route.js |
| Controller | src/controllers/invoice.controller.js → getInvoices |
| Service | src/services/invoice.service.js → getInvoices |
| Response mapper | src/utils/invoice-sync-response-mapper.js → mapInvoiceToSyncPayloadShape, nestAddonsIntoParentItems |
| Payments (read) | src/data/repositories/PaymentRepository.js → getPaymentsByUuids |
| Modifier normalize (write) | src/utils/invoice-refactor.js → serializeModifier, normalizeItem |
| Validation schema | src/validations/invoice.validation.js |
| Repository (query) | src/data/repositories/InvoiceRepository.js → getInvoicesWithFilters |
| Sync payload reference | docs/INVOICE_REQUEST.md |
| Auth middleware | src/middlewares/jwtAuth.js |