README docs INVOICE_REQUEST.md

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

  1. Route middleware
    • Validation runs first (validate(invoiceValidation.syncInvoice)): the body must satisfy the Joi schema above (including required request_id on each invoice). On failure, the request is rejected with a validation error (before auth).
    • JWT (jwtAuth): the user is attached to req.user.
  2. invoiceService.syncInvoice (inside a DB transaction context) For each element in the array:
    • Reads uuid, customer_id, location_id, and request_id. If uuid is missing, the service throws 400 with a message that UUID is required for invoice synchronization.
    • If request_id is missing or empty after trim, the service throws 400 with request_id is required for invoice synchronization. This service never generates a request_id.
    • Same-batch idempotency: if another element in the same request already used the same request_id, the later row reuses the first row's uuid / customer_invoice_id / order_no and is not enqueued again (internal skipQueue; it is stripped from the JSON response).
    • Cross-request idempotency (Redis): key inv_req_id:{request_id} (see src/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). Duplicate request_id responses include request_id_status (0 | 1 | 2) plus customer_invoice_id / order_no when 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, or max(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 to inv-uuid:{uuid}.
    • Each order object is enriched with:
      • order_no — order number
      • customer_invoice_id — customer invoice id (numeric id used as invoice number in this flow)
      • cachedtrue when numbers came from Redis or an idempotent replay, false when newly resolved in this pass
      • request_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
  3. Queue (asynchronous) Only orders that are not idempotent duplicates are passed to orderProcessingQueue.processOrders, which adds one BullMQ job per such order to the order-processing queue. The HTTP handler does not wait for the worker to finish persisting the invoice.
  4. Worker (invoiceSyncWorker) — runs after the HTTP response When the job runs, for each order it roughly:
    • Runs validateInvoice against the payload and user.
    • refactorInvoice — normalizes / reshapes fields for persistence (maps client notes[] → DB order_notes).
    • Fills crm_user_name from CRM when crm_user_id is set and name is empty.
    • Normalizes modified_by_user_ids, created_at / updated_at / paid_at, and payment_method (objects/arrays stringified where needed).
    • syncInvoice (DB layer)handleInvoice then handleInvoiceItems: insert, update, or skip (e.g. already-paid scenarios per handleInvoice rules).
    • If not skipped: insertPayments when payment_method is non-empty.
    • Reloads the invoice from the DB by UUID.

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.jssyncInvoice
request_id Redis helpers src/utils/invoiceRequestIdCache.js
Queue enqueue src/queue/order-processing-queue.jsprocessOrders
Worker src/workers/invoiceSyncWorker.js
DB upsert orchestration src/utils/invoiceSyncService.js, handleInvoice, handleInvoiceItems