Daily Sales API
Daily sales / end-of-day report for POS clients. Returns the same JSON payload as legacy GET /api/v2/today-sales-v3 in web-application (createTodaySalesJsonV4).
Authentication: jwtAuth — JWT in Authorization: Bearer <token>. Tenant scope (customer_id, location_id) comes from the token.
Frontend handout (shorter): frontend-handout/07-DAILY-SALES.md
Route
| Method | Path | Handler |
|---|---|---|
GET |
/v1/daily-sales |
Daily sales report for the requested business day |
Legacy / gateway paths (migration reference):
| Legacy (web-application) | Invoice service |
|---|---|
GET /api/v2/today-sales-v3 |
GET /v1/daily-sales |
GET /today-sales-v3 (TRACK gateway) |
GET /v1/daily-sales |
web-pos: invoiceApiSlice → GET {INVOICE_SERVICE_URL}/daily-sales (e.g. http://localhost:8080/v1/daily-sales).
Authentication
Authorization: Bearer <jwt>
| Claim | Aliases | Required |
|---|---|---|
customer_id |
customerId |
yes |
location_id |
locationId |
yes |
Optional JWT fields used in the response:
| Claim | Used for |
|---|---|
currency |
Pending / late-closure row currency (default AED) |
is_master_locations_enabled |
Product join scope (master vs location-scoped products) |
allow_locations |
Enforced when overriding location via query |
user_id / id |
Open-shift lookup when shift=1 |
name / username |
Actual-price module lookup |
Missing customer_id or location_id → 400 Authentication must include customer_id and location_id.
Invalid JWT → 401.
GET /v1/daily-sales
Returns a plain JSON object (no { success, data } wrapper).
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
date |
string | today | Single business day (DD-MM-YYYY). Legacy alias used by web-pos |
from_date |
string | — | Range start (DD-MM-YYYY). Overrides date when set |
to_date |
string | — | Range end (DD-MM-YYYY) |
paid_at |
string | — | Filter completed invoices by paid date (DD-MM-YYYY) instead of created_at window |
brand_id |
number | string | — | Single brand filter |
brands |
string | — | Comma-separated or JSON array of brand ids |
close_by_user_id |
number | — | Only invoices closed by this user |
shift |
1 | true |
— | Restrict to the authenticated user's open shift |
shift_id |
number | — | Restrict to a specific shift id |
location_id |
number | — | Override location (must be allowed by JWT allow_locations) |
force_location_ids |
string | array | — | Multi-location report (comma-separated or JSON array) |
app_version |
string | — | Affects modifier / item qty formatting |
build_number |
number | string | — | Used with app_version for qty typing |
Date resolution
- If
from_dateand/orto_dateare provided, they define the range (swapped iffrom > to). - Else if
dateis provided, both ends of the range equal that day. - Else defaults to today (server calendar date at request time).
- Invalid date format → 400
Invalid date parameter(s): … Use DD-MM-YYYY.
Business day window
- Resolved from
configrowstime_zone+opening_hourper location. - Invoices are included when
created_atfalls betweenstart_utcandend_utcof the business day (unlesspaid_atfilter is used). - Missing timezone or opening hour → 400
Missing Time Zone or Business Hours.
Shift filter
- When
shift=1/shift=true/shift_idis set but no matching open shift exists, returns a valid empty report (zeros,shift_filter_applied: true) — not an error.
There is no Joi validation on this route; unknown query keys are ignored.
Example requests
GET /v1/daily-sales?date=09-07-2026
Authorization: Bearer <jwt>
GET /v1/daily-sales?date=09-07-2026&close_by_user_id=4734&app_version=1.1.1&build_number=16
Authorization: Bearer <jwt>
GET /v1/daily-sales?from_date=01-07-2026&to_date=09-07-2026&brand_id=12
Authorization: Bearer <jwt>
Response envelope
Top-level object. Amounts are numbers (parsed from DB decimals). Timestamps:
| Field | Type | Description |
|---|---|---|
ts |
number | Server millis at response time |
time |
string | Current local time string for the customer timezone (e.g. "2:30 PM") |
date_range |
object | { from, to } as YYYY-MM-DD |
Sales totals
| Field | Type | Description |
|---|---|---|
totalAmount |
number | Completed sales total (incl. late closure) |
total_sales |
number | Same as totalAmount |
total_orders |
number | Completed order count |
total_payable |
number | Sum of completed invoice payable |
total_without_vat |
number | total_payable - total_vat |
gross_sales |
number | Payable + discounts |
total_vat |
number | VAT on completed orders |
avg_invoice_bill |
number | Average completed ticket |
Line items & categories
| Field | Type | Description |
|---|---|---|
items |
array | Sold products (non-addon, non-refunded), aggregated by product |
addons |
array | Add-on lines |
refund_items |
array | Refunded lines |
modifiers |
array | Aggregated modifier lines |
itemsCat |
array | { type, total } per product type (+ modifiers, add-ons, refunds) |
Item row (typical)
{
"type": "Beverages",
"name": "Latte",
"qty": 12,
"total": 180.5,
"unit_id": 1,
"is_addon": 0,
"is_refunded": 0,
"extra": []
}
Payments
| Field | Type | Description |
|---|---|---|
itemsPayment |
array | Per payment method: { payment_method, total, transactions, tip_amount? } |
| Also includes synthetic rows: Unpaid Order, Discount Amount, Tip Amount, Points, Top-ups |
Use total_payable (or totalAmount) for the payment table footer total in web-pos.
Order types
| Field | Type | Description |
|---|---|---|
order_type |
object | Counts keyed by type (dine_in, drive_thru, takeaway, …) plus void_orders, refunded_items |
order_types |
array | { name, count, amount } sorted by amount |
order_type_summary |
object | Extended breakdown with share % (see order_type_summary.rows) |
Users & shifts
| Field | Type | Description |
|---|---|---|
user_statistics |
object | open_orders, completed_orders, created_by_users, closed_by_users — each { users[], total_amount, total_orders } |
active_shift_id |
number | null | Shift used when shift filter applied |
shift_filter_applied |
boolean | Whether shift scoping was requested |
Wallet, cash, voids
| Field | Type | Description |
|---|---|---|
wallet_orders |
object | { total_amount, total_count } |
wallet_topups |
object | { transaction_type: "debit", amount, count } |
void_orders |
object | { title, no_of_cancelled_order, no_of_items_refund, total, transaction[] } |
withdraw_amount |
number | Cash out total |
credit_amount |
number | Cash in total |
Discounts & CRM
| Field | Type | Description |
|---|---|---|
discount_summary |
array | { discount_name, count, amount } |
discount_amount |
object | { title, total, transactions } |
total_customers |
object | { count } — distinct CRM customers |
total_discounts |
object | { total_amount, total_applications } |
order_discount_summary |
object | Per-discount-name rollup |
item_discount_summary |
object | Per-line discount detail |
Other
| Field | Type | Description |
|---|---|---|
dining_guests |
number | Sum of dining_guests on completed invoices |
item_transferred |
number | Transferred line count |
hourly |
array | { hour, value, order_count } distribution |
pending_orders |
array | Open invoices summary |
late_closure |
array | Completed in period but created before opening |
invoice_begin / invoice_end |
number | null | First / last customer_invoice_id in scope |
invoices_count |
number | Invoice count in scope |
location_id |
number | Primary location |
force_location_ids |
number[] | Present when multi-location |
Tip & loyalty
| Field | Type | Description |
|---|---|---|
tip_amount |
object | { title: "Tip Amount", total, transactions } |
points |
object | { title: "Points", total, transactions } — burn points value |
unpaid_order |
object | { title: "Unpaid Order", total, transactions } |
Errors
| HTTP | Condition | Body shape |
|---|---|---|
| 400 | Missing JWT claims | { success: false, code: 400, message: "…" } |
| 400 | Invalid date param | Plain { status: false, message: "Invalid date parameter(s): …" } from report builder |
| 400 | Missing business hours | Plain { message: "Missing Time Zone or Business Hours" } |
| 401 | Invalid / expired JWT | { success: false, code: 401, message: "…" } |
| 403 | Location not in allow_locations |
{ success: false, code: 403, message: "Access denied for this location" } |
| 500 | Unhandled server error | { success: false, code: 500, message: "…" } |
On success, the body is the report object directly (not the error envelope above).
Implementation architecture
dailySales.route.js
→ dailySalesController.getDailySales
→ dailySalesService.getDailySales
→ createTodaySalesReport.js # aggregation + response (legacy parity)
→ DailySalesRepository # Sequelize reads
├── Invoice + InvoiceItem
├── Payment
└── late-closure Invoice queries
→ raw SQL (pool)
├── config (business hours)
├── wallet_transactions
├── withdrawal / cash_in
└── CRM discount summary queries
| Concern | Path |
|---|---|
| Route | src/routes/v1/dailySales.route.js |
| Controller | src/controllers/dailySales.controller.js |
| Service | src/services/dailySales.service.js |
| Report builder | src/daily-sales/createTodaySalesReport.js |
| Sequelize repository | src/data/repositories/DailySalesRepository.js |
| Scope → WHERE helpers | src/daily-sales/utils/salesScopeBuilder.js |
| Decimal parsing | src/daily-sales/utils/decimal.js |
| Location/date SQL fragments | src/daily-sales/utils/locationDateSql.js |
| Legacy reference | web-application/.../createTodaySalesJsonV4.js |
Configuration & data dependencies
Requires MySQL tables used by web-application POS reporting:
invoices,invoice_items,payments,productsconfig(time_zone,opening_hourper customer/location)crm_users,discounts,shiftswallet_transactions,withdrawal,cash_in
Per-location config must have valid time_zone (e.g. +04:00) and opening_hour (e.g. 05:00).
Client migration checklist
- Point base URL to invoice service:
NEXT_PUBLIC_INVOICE_SERVICE_URL//v1. - Replace
GET /api/v2/today-sales-v3withGET /v1/daily-sales. - Keep query params (
date,close_by_user_id,app_version, etc.) unchanged. - Parse response as a top-level object (not wrapped in
data). - Use
total_payable ?? totalAmountfor payment footer totals. - Map
itemsCat+items/addons/modifiers/refund_itemsfor category tables (see web-posmapByCategory).