Invoice sync — request payload (POST …/sync)
The HTTP body is a JSON array. Each element is one invoice (order) to synchronize. Validation is defined in src/validations/invoice.validation.js (syncInvoice.body). Additional keys are allowed (.unknown(true) on the invoice object and on each line item).
Authentication: the route is protected with jwtAuth after validation — send a valid JWT as your app normally does.
To cancel an existing invoice (set status to cancelled and run side effects), use PUT …/cancel instead of sync.
Enumerations (must match exactly when present)
| Field | Allowed values |
|---|---|
status (invoice) |
"open", "cancelled", "completed" (INVOICE_STATUS) |
discount_type (line item, if sent) |
"percentage", "amount" (INVOICE_ITEM_DISCOUNT_TYPE) |
Boolean flags (0 / 1)
Most tinyint/boolean fields use a shared rule: number 0 or 1, optional, default 0 when omitted.
| Rule | Meaning |
|---|---|
0 |
false / off |
1 |
true / on |
| omitted | defaults to 0 (unless noted below) |
Exceptions:
| Field | Notes |
|---|---|
is_syspos |
default 1 |
is_combo, is_internal |
also allow explicit null |
is_deleted |
0 or 1 only (default 0) |
Invoice-level flags: is_paid, is_qr_payment, is_wallet_used, is_ticket_available, is_vat_on_discount_exclude, is_wastage, is_discount_on_total, is_syspos, is_accepted, is_updated, is_refunded, is_kds_printed, is_invoice_printed, is_loyalty_enable, is_sms_enable, row_discount, external_order_ready.
Item-level flags: is_addon, is_refunded, is_updated, is_deleted, is_wastage, ri, is_prepared.
Schema overview (matches invoice.validation.js)
Invoice — required
| Field | Type |
|---|---|
uuid |
string |
request_id |
string (trimmed, min length 1) — client idempotency key |
status |
enum — see above |
customer_id |
number |
location_id |
number |
user |
string |
created_at |
number (Unix timestamp, seconds) |
updated_at |
number (Unix timestamp, seconds) |
subtotal |
number |
vat |
number |
payable |
number |
items |
array of line items (non-empty in practice) |
order_type |
string |
Invoice — optional groups
| Group | Fields |
|---|---|
| Basics | user_id |
| Timestamps | paid_at, done_at, rider_pickup_at, rider_arrived_at — number | null |
| Finance | discountamount, discountpct, discount_id, discount_reason, paid, change, tip_amount, tip, charges_fee, delivery_charges, delivery_fee, packaging_fee, service_percentage, service_amount, service_charge, refund_amount |
| Calculated totals | gross_total, net_total, items_subtotal, items_vat_amount, items_total, items_discount_amount |
| Customer snapshot | info_name, info_address, info_phone, info_email, phone_number — strings, default "" |
| Order | items_number, items_count, products_count, order_no, customer_invoice_id, round |
| Payment | payments (array of objects), is_paid, is_qr_payment, qr_payment_link, is_wallet_used |
| Table & service | table_id, order_type_icon_key, token_number, plate_number, dining_guests, guest_name |
| CRM | crm_user_id, crm_user_name, crm_delivery_area |
| Brand & channel | brand_id, channel_id, channel_name, channel |
| Notes & timezone | note (string), notes (string array → DB order_notes), order_notes (alias), period, timezone |
| Flags | see boolean flags above; ticket_validate_at — number | null |
| Loyalty | is_loyalty_enable, loyalty_mobile_number, loyalty_mobile_code, earn_points, burn_points, burn_points_amount, available_points, available_points_amount |
| Coupon | coupon_id, coupon_code, coupon_amount |
| Printing | printed_invoice_receipts, printed_kds_receipts |
| App info | app_version, app_os_version, build_number, os_type, updated_app_version, updated_os_type |
| User tracking | created_by_user_id, close_by_user_id, modified_by_user_ids (string or array of numbers) |
| Integrations | otter_account_id, otter_id, deliverect_account_id, deliverect_id, deliverect_date, deliveroo_id, aggregator_order_id, pos_receipt_id, pos, pos_id |
| Misc | by, delivery_cost, number_of_customers, exchange_rate, paid_in_usd, multi_location_order_id, shift_id, is_sms_enable |
Line item (items[]) — required
| Field | Type |
|---|---|
name |
string |
product_id |
number |
qty |
number |
actual_price |
number |
invoice_uuid |
string — should match parent uuid |
vat_pct |
number |
customer_id |
number |
location_id |
number |
Line item — optional groups
| Group | Fields |
|---|---|
| Core | name_localize, price, subtotal |
| Tax & discount | vat, discount_type, discount_pct, discount_amount, discount_id, discount_name |
| Addons | addons (array), is_addon, addon_linked_with |
| Modifier & combo | modifiers (array), combo_item_id, is_combo, combo_items |
| State & refund | note, is_refunded, refund_quantity, refund_reason, refund_pm_type, refund_amount, is_updated, updated_qty, is_deleted, deleted_qty, deleted_at, is_wastage, ri, item_status (default "active"), disposition_type, disposition_reason |
| Metadata | transferred_to, transferred_to_uuid, transferred_quantity, transferred_to_uuid_list, transferred_quantities, transfer_recipients, created_at, updated_at, product_type, is_internal, sort_order, plu, otter_account_id, delivery_cost, tip, by, otter_id, is_prepared, base_price, brand_id, label_id, preparation_time, linked_uuid, original_linked_uuid, item_type, identifier, updated_by_user_ids (array of numbers), re, round (default 1) |
| Calculated / cost | item_price_after_discount, line_vat, line_total, line_total_without_vat, coupon_amount, coupon_id, coupon_code, product_recipe_cost, total_cost, modifiers_cost_amount, cost_snapshot_at |
Annotated payload example (JSON with comments)
Use the comments as the source of truth for required vs optional. Defaults apply when the field is omitted and Joi supplies a default.
[
{
// ─── Identity & basics (required unless noted) ─────────────────────
"uuid": "550e8400-e29b-41d4-a716-446655440000", // REQUIRED
"request_id": "client-op-2025-05-11-001", // REQUIRED — client idempotency key
"status": "open", // REQUIRED — "open" | "cancelled" | "completed"
"customer_id": 1, // REQUIRED
"location_id": 2, // REQUIRED
"user": "[email protected]", // REQUIRED
"user_id": 10, // OPTIONAL
// ─── Timestamps (Unix seconds) ─────────────────────────────────────
"created_at": 1710000000, // REQUIRED — number
"updated_at": 1710000001, // REQUIRED — number
"paid_at": null, // OPTIONAL — number | null
"done_at": null, // OPTIONAL — number | null
"rider_pickup_at": null, // OPTIONAL — number | null
"rider_arrived_at": null, // OPTIONAL — number | null
// ─── Money & totals ───────────────────────────────────────────────
"subtotal": 100, // REQUIRED
"vat": 5, // REQUIRED
"payable": 105, // REQUIRED
"discountamount": 0, // OPTIONAL — default 0
"discountpct": 0, // OPTIONAL — default 0
"discount_id": null, // OPTIONAL — number | null
"discount_reason": "", // OPTIONAL — default ""
"paid": 0, // OPTIONAL — default 0
"change": 0, // OPTIONAL — default 0
"tip_amount": 0, // OPTIONAL — default 0
"tip": 0, // OPTIONAL — default 0
"charges_fee": 0, // OPTIONAL — default 0
"delivery_charges": 0, // OPTIONAL — default 0
"delivery_fee": 0, // OPTIONAL — default 0
"packaging_fee": 0, // OPTIONAL — default 0
"service_percentage": 0, // OPTIONAL — default 0
"service_amount": 0, // OPTIONAL — default 0
"service_charge": 0, // OPTIONAL — default 0
"refund_amount": 0, // OPTIONAL — default 0
// ─── Aggregated line metrics ──────────────────────────────────────
"gross_total": 105, // OPTIONAL
"net_total": 100, // OPTIONAL
"items_subtotal": 100, // OPTIONAL — default 0
"items_vat_amount": 5, // OPTIONAL — default 0
"items_total": 105, // OPTIONAL — default 0
"items_discount_amount": 0, // OPTIONAL — default 0
// ─── Customer snapshot (all optional, default "") ─────────────────
"info_name": "Walk-in",
"info_address": "123 St",
"info_phone": "+10000000000",
"info_email": "[email protected]",
"phone_number": "",
// ─── Order / items ────────────────────────────────────────────────
"items": [
{
// REQUIRED:
"name": "Burger",
"name_localize": "", // OPTIONAL — default ""
"product_id": 42,
"qty": 2,
"actual_price": 5.5,
"invoice_uuid": "550e8400-e29b-41d4-a716-446655440000",
"vat_pct": 5,
"customer_id": 1,
"location_id": 2,
// OPTIONAL (pricing):
"price": 5.5,
"subtotal": 11,
// OPTIONAL (tax & discount):
"vat": 0.55, // default 0
"discount_type": "percentage", // "percentage" | "amount"
"discount_pct": 0, // default 0
"discount_amount": 0, // default 0
"discount_id": null,
"discount_name": "",
// OPTIONAL (addons):
"addons": null, // array of objects | null
"is_addon": 0, // 0 | 1 — default 0
"addon_linked_with": "",
// OPTIONAL (modifier / combo):
"modifiers": null, // array of objects | null
"combo_item_id": null,
"is_combo": null, // 0 | 1 | null
"combo_items": null,
// OPTIONAL (refunds / deletes / notes):
"note": "",
"is_refunded": 0, // 0 | 1 — default 0
"refund_quantity": 0,
"refund_reason": null,
"refund_pm_type": "cash",
"refund_amount": null, // OPTIONAL — number | null
"is_updated": 0, // 0 | 1 — default 0
"updated_qty": 0,
"is_deleted": 0, // 0 | 1 — default 0
"deleted_qty": 0,
"deleted_at": null, // number | null
"is_wastage": 0, // 0 | 1 — default 0
"ri": 0, // 0 | 1 — default 0
"item_status": "active", // OPTIONAL — default "active"
"disposition_type": null, // OPTIONAL — e.g. "wastage" | "return"
"disposition_reason": null, // OPTIONAL
// OPTIONAL (metadata):
"transferred_to": 0,
"transferred_to_uuid": "",
"transferred_quantity": 0,
"transferred_to_uuid_list": [], // OPTIONAL — array | JSON string → DB text
"transferred_quantities": [], // OPTIONAL — array | JSON string → DB text
"transfer_recipients": [], // OPTIONAL — array | JSON string → DB text
"created_at": 1710000000, // number — optional
"updated_at": 1710000001, // number — optional
"product_type": "product",
"is_internal": null, // 0 | 1 | null
"sort_order": 0,
"plu": "",
"otter_account_id": "",
"delivery_cost": "",
"tip": 0,
"by": "",
"otter_id": "",
"is_prepared": 0, // 0 | 1 — default 0
"base_price": 5.5,
"brand_id": null,
"label_id": null,
"preparation_time": null,
"linked_uuid": "",
"original_linked_uuid": null, // OPTIONAL — links disposition / transfer lineage
"item_type": "product",
"identifier": "",
"updated_by_user_ids": null, // array of numbers | null
"re": "", // default ""
"round": 1, // default 1
// OPTIONAL (calculated / cost):
"item_price_after_discount": 11,
"line_vat": 0.55,
"line_total": 11.55,
"line_total_without_vat": 11,
"coupon_amount": 0,
"coupon_id": null,
"coupon_code": "",
"product_recipe_cost": 0,
"total_cost": 0,
"modifiers_cost_amount": null, // OPTIONAL — usually set by cost snapshot
"cost_snapshot_at": null // OPTIONAL — unix / datetime | null
// Any other keys are allowed on items (.unknown(true)).
}
], // REQUIRED
"items_number": 1, // OPTIONAL — default 0
"items_count": 1, // OPTIONAL — default 0
"products_count": 1, // OPTIONAL — default 0
"order_no": 0, // OPTIONAL — default 0 (server may overwrite)
"customer_invoice_id": null, // OPTIONAL
"round": 1, // OPTIONAL
// ─── Payment ──────────────────────────────────────────────────────
"payments": null, // OPTIONAL — array of objects | null
"is_paid": 0, // 0 | 1 — default 0
"is_qr_payment": 0, // 0 | 1 — default 0
"qr_payment_link": "", // default ""
"is_wallet_used": 0, // 0 | 1 — default 0
// ─── Table & service ──────────────────────────────────────────────
"table_id": null, // OPTIONAL — number | null
"order_type": "dine-in", // REQUIRED
"order_type_icon_key": "", // OPTIONAL
"token_number": 0, // OPTIONAL — number | null
"plate_number": "", // OPTIONAL
"dining_guests": 0, // OPTIONAL — default 0
"guest_name": "", // OPTIONAL — default ""
// ─── CRM ──────────────────────────────────────────────────────────
"crm_user_id": 0, // OPTIONAL — default 0
"crm_user_name": "", // OPTIONAL — default ""
"crm_delivery_area": null, // OPTIONAL — string | null
// ─── Brand & channel ──────────────────────────────────────────────
"brand_id": null, // OPTIONAL — number | null
"channel_id": 0, // OPTIONAL — default 0
"channel_name": "", // OPTIONAL
"channel": "", // OPTIONAL
// ─── Notes & timezone ─────────────────────────────────────────────
"note": "", // OPTIONAL — single free-text note (default "")
"notes": ["No onions", "Extra spicy"], // OPTIONAL — array of strings; persisted as `order_notes` in MySQL (legacy invoiceSyncV3)
"period": null, // OPTIONAL
"timezone": "Asia/Dubai", // OPTIONAL
// ─── Flags (0 | 1, default 0 unless noted) ────────────────────────
"is_ticket_available": 0,
"ticket_validate_at": null, // number | null
"is_vat_on_discount_exclude": 0,
"is_wastage": 0,
"is_discount_on_total": 0,
"is_syspos": 1, // default 1
"is_accepted": 0,
"is_updated": 0,
"is_refunded": 0,
"is_kds_printed": 0,
"is_invoice_printed": 0,
"row_discount": 0, // 0 | 1 — item discount_pct applies to product+modifiers+extras+addons when 1
"external_order_ready": 0, // 0 | 1 — default 0
// ─── Loyalty ──────────────────────────────────────────────────────
"is_loyalty_enable": 0, // 0 | 1 — default 0
"loyalty_mobile_number": "",
"loyalty_mobile_code": "",
"earn_points": 0,
"burn_points": 0,
"burn_points_amount": 0,
"available_points": 0,
"available_points_amount": 0,
// ─── Coupon (header) ──────────────────────────────────────────────
"coupon_id": 0,
"coupon_code": "",
"coupon_amount": 0,
// ─── Printing counters ────────────────────────────────────────────
"printed_invoice_receipts": 0,
"printed_kds_receipts": 0,
// ─── App info (all optional) ──────────────────────────────────────
"app_version": "1.0.0",
"app_os_version": "14",
"build_number": null, // number | null
"os_type": "android",
"updated_app_version": "",
"updated_os_type": "",
// ─── User tracking ────────────────────────────────────────────────
"created_by_user_id": null,
"close_by_user_id": null,
"modified_by_user_ids": null, // string | array of numbers
// ─── Integrations ─────────────────────────────────────────────────
"otter_account_id": "",
"otter_id": "",
"deliverect_account_id": "",
"deliverect_id": "",
"deliverect_date": 0,
"deliveroo_id": "",
"aggregator_order_id": "",
"pos_receipt_id": "",
"pos": "",
"pos_id": "",
// ─── Misc ─────────────────────────────────────────────────────────
"by": "",
"delivery_cost": "",
"number_of_customers": 1,
"exchange_rate": 1,
"paid_in_usd": 0,
"multi_location_order_id": null,
"shift_id": null,
"is_sms_enable": 0 // 0 | 1 — default 0
// Any other keys are allowed on the invoice (.unknown(true)).
// Legacy keys (e.g. payment_method, addOns, modifier) may still be sent
// but are not defined in the Joi schema — prefer payments, addons, modifiers.
}
]
Required fields (checklist)
Per invoice: uuid, request_id, status, customer_id, location_id, user, created_at, updated_at, subtotal, vat, payable, items, order_type.
Per line item (items[]): name, product_id, qty, actual_price, invoice_uuid, vat_pct, customer_id, location_id.
What happens to the payload after it reaches the server
- Route middleware
- Validation runs first (
validate(invoiceValidation.syncInvoice)): the body must satisfy the Joi schema above (including requiredrequest_idon each invoice). On failure, the request is rejected with a validation error (before auth). - JWT (
jwtAuth): the user is attached toreq.user.
- Validation runs first (
invoiceService.syncInvoice(inside a DB transaction context) For each element in the array:- Reads
uuid,customer_id,location_id, andrequest_id. Ifuuidis missing, the service throws400with a message that UUID is required for invoice synchronization. - If
request_idis missing or empty after trim, the service throws400withrequest_id is required for invoice synchronization. This service never generates arequest_id. - Same-batch idempotency: if another element in the same request already used the same
request_id, the later row reuses the first row'suuid/customer_invoice_id/order_noand is not enqueued again (internalskipQueue; it is stripped from the JSON response). - Cross-request idempotency (Redis): key
inv_req_id:{request_id}(seesrc/utils/invoiceRequestIdCache.js, TTL 24h). Value is a small state machine:"0"(accepted, numbers not yet assigned),"1,customer_invoice_id,order_no,uuid"(numbers assigned),"2,customer_invoice_id,order_no,uuid"(inventory handoff reached, set in the worker immediately before publishing to inventory). Duplicaterequest_idresponses includerequest_id_status(0|1|2) pluscustomer_invoice_id/order_nowhen known; the job is skipped. The service does not delete these keys; they expire when the TTL elapses. - Per-UUID Redis (
inv-uuid:{uuid}, 24h TTL): if numbers were already cached for this invoice UUID, they are reused. - Otherwise numbers are resolved from the invoices table (existing row by
uuid+customer_id, ormax(customer_invoice_id)/max(order_no)for the business date). When new numbers are allocated, invoice / order counter tables are updated to match; the pair is then written toinv-uuid:{uuid}. - Each order object is enriched with:
order_no— order numbercustomer_invoice_id— customer invoice id (numeric id used as invoice number in this flow)cached—truewhen numbers came from Redis or an idempotent replay,falsewhen newly resolved in this passrequest_id_status— (optional, idempotent replays only)0= request accepted but numbers not yet assigned;1= numbers assigned (worker may still be running);2= inventory handoff reached for completed invoices
- Reads
- Queue (asynchronous)
Only orders that are not idempotent duplicates are passed to
orderProcessingQueue.processOrders, which adds one BullMQ job per such order to theorder-processingqueue. The HTTP handler does not wait for the worker to finish persisting the invoice. - Worker (
invoiceSyncWorker) — runs after the HTTP response When the job runs, for each order it roughly:- Runs
validateInvoiceagainst the payload and user. refactorInvoice— normalizes / reshapes fields for persistence (maps clientnotes[]→ DBorder_notes).- Fills
crm_user_namefrom CRM whencrm_user_idis set and name is empty. - Normalizes
modified_by_user_ids,created_at/updated_at/paid_at, andpayment_method(objects/arrays stringified where needed). syncInvoice(DB layer) —handleInvoicethenhandleInvoiceItems: insert, update, or skip (e.g. already-paid scenarios perhandleInvoicerules).- If not skipped:
insertPaymentswhenpayment_methodis non-empty. - Reloads the invoice from the DB by UUID.
- Runs
So: the immediate outcome of the HTTP call is the enriched array (numbers + cached flag). Persistence and side effects (DB rows, items, payments) happen in the background worker on the same enriched payload.
Related code
| Piece | Path |
|---|---|
| Validation | src/validations/invoice.validation.js |
| Route | src/routes/v1/invoice.route.js |
| Service (numbers + queue) | src/services/invoice.service.js → syncInvoice |
request_id Redis helpers |
src/utils/invoiceRequestIdCache.js |
| Queue enqueue | src/queue/order-processing-queue.js → processOrders |
| Worker | src/workers/invoiceSyncWorker.js |
| DB upsert orchestration | src/utils/invoiceSyncService.js, handleInvoice, handleInvoiceItems |