Local: http://flo.local:3001 or http://<local-ip>:3001
The standalone Server App listens on http://<local-ip>:3003 and forwards
these protected routes to the local API. Its customer-creation forwarder is
limited to 150 requests per minute per client IP, including LAN/private IPs.
The POST /api/printers/print-kot and POST /api/printers/print-bill
forwarders share a limit of 30 requests per minute per client IP. These limits
run before Server App authentication and return HTTP 429 when exceeded.
Authenticate user and receive JWT token.
Request:
{
"email": "chef1@flo.local",
"password": "chef123"
}Response (200):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 86400,
"user": {
"id": "chef-1",
"name": "Chef One",
"email": "chef1@flo.local",
"role": "chef",
"category_ids": ["cat-1", "cat-2"]
}
}Error (401):
{
"error": "Invalid credentials"
}Change the authenticated user's password.
Headers: Authorization: Bearer <token>
Request:
{
"current_password": "chef123",
"password": "NewChef123"
}Response (200):
{
"message": "Password changed successfully"
}Incorrect current-password attempts return 400 with attempts_remaining. After five incorrect attempts for the same user, password-change attempts for that user are locked for five minutes; the fifth response reports attempts_remaining: 0 and lockout_minutes: 5. Further attempts during the lockout return 429. A valid current password or an expired lockout resets the per-user failed-attempt counter. This endpoint also uses the LAN-aware authentication rate limiter.
List all users (owner/manager only).
Headers: Authorization: Bearer <token>
Response (200):
{
"users": [
{
"id": "user-1",
"name": "Owner",
"email": "admin@flo.local",
"role": "owner",
"is_active": 1
}
]
}Create new user.
Headers: Authorization: Bearer <token>
Request:
{
"name": "Chef One",
"email": "chef1@flo.local",
"password": "chef123",
"role": "chef",
"category_ids": ["cat-1", "cat-2"]
}Response (201):
{
"success": true,
"id": "chef-1"
}Update user details.
Headers: Authorization: Bearer <token>
Request:
{
"name": "Updated Name",
"role": "manager",
"category_ids": ["cat-1", "cat-2", "cat-3"]
}Delete user.
Headers: Authorization: Bearer <token>
List all categories.
Headers: Authorization: Bearer <token>
Response (200):
{
"categories": [
{ "id": "cat-1", "name": "Food", "is_active": 1 },
{ "id": "cat-2", "name": "Beverages", "is_active": 1 },
{ "id": "cat-3", "name": "Desserts", "is_active": 1 }
]
}Create category.
Headers: Authorization: Bearer <token>
Request:
{
"name": "Appetizers"
}Update category.
Delete category.
List all products.
Headers: Authorization: Bearer <token>
Query params: ?category_id=cat-1&is_active=1
Response (200):
{
"products": [
{
"id": "prod-1",
"name": "Cheeseburger",
"price": 250.0,
"category_id": "cat-1",
"is_active": 1,
"has_addons": true
}
]
}Create product.
Headers: Authorization: Bearer <token>
Request:
{
"name": "Veggie Wrap",
"price": 180.0,
"category_id": "cat-1",
"has_addons": false
}Product stock_quantity is the current stock cache. Every non-zero stock
change is committed atomically with an append-only row in the inventory
movement ledger. Supplying a non-zero stock_quantity when creating a product
records an adjustment movement with reference_type: "opening_balance".
The optional reason field supplies the opening-balance reason.
Update product (owner or manager only). Supplying stock_quantity sets the
target stock through an audited adjustment movement instead of writing the
cache directly. The optional reason field is stored with that movement;
omitting stock_quantity leaves stock unchanged.
Headers: Authorization: Bearer <token>
Apply a manual stock adjustment (owner or manager only).
Headers: Authorization: Bearer <token>
Request:
{
"action": "increase",
"quantity": 5,
"reason": "Counted unopened cases"
}action is one of set, increase, or decrease; quantity must be a
non-negative number. The response is the updated product. A zero net change
does not create a movement. Manual adjustments use movement type adjustment.
List product inventory movements (owner or manager only), newest first.
Headers: Authorization: Bearer <token>
Query params:
?product_id=prod-1- Filter by product?movement_type=sale|cancel_restore|adjustment- Filter by movement type?reference_type=order_item&reference_id=123- Filter by a local or imported source-reference pair?before_id=42- Continue before a movement ID returned by the previous page?per_page=50- Page size, capped at 500 (default 50)
Response (200):
{
"movements": [
{
"id": 42,
"product_id": "prod-1",
"product_name": "Cheeseburger",
"quantity_delta": -2,
"movement_type": "sale",
"reference_type": "order_item",
"reference_id": "123",
"reason": null,
"actor_user_id": "user-1",
"actor_name": "Owner",
"stock_after": 8,
"created_at": "2025-03-31 12:00:00",
"imported_by_user_id": null,
"import_batch_id": null,
"source_actor_user_id": null,
"source_reference_type": null,
"source_reference_id": null,
"source_reason": null,
"source_created_at": null
}
],
"nextCursor": 17
}nextCursor is omitted when there are no more results. Movement types are
sale, cancel_restore, and adjustment; the latter includes opening
balances and manual adjustments. Sales reduce stock, while cancellation and
restoration flows record their stock delta and actor in the same transaction.
Imported movements use the authenticated importer as actor_user_id, set the
local reference to import plus the import_batch_id, and keep the supplied
actor, reference, reason, and timestamp in the source_* fields; source
metadata is not authenticated attribution.
Delete (deactivate) product.
List addon groups.
Headers: Authorization: Bearer <token>
Create addon group.
Request:
{
"name": "Sauce Options",
"addons": [
{ "name": "Extra Cheese", "price": 20 },
{ "name": "No Onions", "price": 0 }
]
}List all tables.
Headers: Authorization: Bearer <token>
Response (200):
{
"tables": [
{ "id": "table-1", "name": "T1", "capacity": 4, "is_active": 1 }
]
}Create table.
Update table.
Delete table.
List orders.
Headers: Authorization: Bearer <token>
Query params:
?status=pending,preparing- Filter by status?date=2025-03-31- Filter by date
Response (200):
{
"orders": [
{
"id": 1,
"order_number": "ORD-001",
"type": "dine_in",
"status": "pending",
"whatsapp_receipt_status": "sent",
"table": { "id": "table-1", "name": "T1" },
"items": [
{
"id": 1,
"product_name": "Cheeseburger",
"quantity": 2,
"status": "pending",
"addons": [{ "id": "addon-1", "name": "Extra Cheese", "price": 20, "quantity": 1 }],
"special_instructions": "No onions"
}
],
"created_at": "2025-03-31T12:00:00Z"
}
]
}whatsapp_receipt_status summarizes the latest outbound bill_receipt
ledger row for each paid bill in the order; a paid bill with no row contributes
no positive status. It is sent when every paid bill's latest status is
sent, delivered, or read; partial when positive and non-positive
statuses are mixed; pending when no positive status exists but at least one
row is queued or typing; failed when no positive or pending status exists
but a row is failed; and null when no matching row exists. The wa.me
share fallback does not create a native ledger row and is not counted as sent.
Create new order.
Headers: Authorization: Bearer <token>
Order item addons reference catalog add-ons by id. Each add-on must be active and linked to the product's add-on group. Add-on name and price are resolved from the catalog (client-supplied names and prices are ignored). Quantity defaults to 1 when omitted and must be a positive integer. service_charge is an optional explicit non-negative per-order amount validated and persisted by the server; omitted/null means 0. Settings' service-charge category selects tax treatment only - it does not define an amount or rate, and no automatic service charge is applied.
Request:
{
"type": "dine_in",
"table_id": "table-1",
"customer_id": "cust-1",
"service_charge": 0,
"items": [
{
"product_id": "prod-1",
"quantity": 2,
"addons": [{ "id": "addon-1", "quantity": 1 }],
"special_instructions": "No onions"
}
]
}Response (201):
{
"order": { ... },
"bill": { ... }
}Get order details.
The response uses the same hydrated order shape as the list endpoint,
including whatsapp_receipt_status.
Update order status.
Request:
{
"status": "cancelled",
"reason": "Customer requested cancellation"
}reason is optional and is stored with inventory movements when status is
cancelled.
Valid transitions:
| Current status | Allowed next statuses |
|---|---|
pending |
preparing, ready, served, completed, cancelled |
preparing |
ready, served, completed, cancelled |
ready |
served, completed, cancelled |
served |
completed, cancelled |
completed |
none (terminal) |
cancelled |
none (terminal) |
Cancelling a pending whole order does not require an override PIN unless one of
its items has already advanced to preparing, ready, served, or completed.
Once kitchen progress has started, an active owner or manager approval PIN is
required. Printing a kitchen ticket alone does not change an order or item
status.
Repeating a request for the order's current status is an idempotent no-op.
Cancelling an order requires a manager PIN when the order has progressed beyond
pending or any item is already in progress. Cancellation restores inventory
only for non-terminal items that recorded an inventory deduction; cancelled,
voided, and accounting-adjustment items are excluded.
List held orders. Requires an authenticated owner, manager, cashier, or server.
Response (200):
{
"orders": [
{
"id": "ho-abc12345",
"tableId": "table-1",
"items": [
{
"id": "line-1",
"product": { "id": "prod-1", "name": "Cheeseburger", "price": 250 },
"quantity": 1,
"addons": [],
"special_instructions": ""
}
],
"customerId": null,
"guestCount": 1,
"orderNotes": "",
"heldAt": "2025-03-31T12:00:00Z"
}
],
"skippedCount": 0
}Create or replace the held order for a table. The response id identifies the
specific row returned to the client; replacing an existing held order creates a
new identity. Requires an authenticated owner, manager, cashier, or server.
Request:
{
"tableId": "table-1",
"items": [
{
"id": "line-1",
"product": { "id": "prod-1", "name": "Cheeseburger", "price": 250 },
"quantity": 1,
"addons": [],
"special_instructions": ""
}
],
"customerId": null,
"guestCount": 1,
"orderNotes": ""
}Response (200):
{
"success": true,
"id": "ho-abc12345"
}Consume the held order only when heldOrderId matches the current row. A
matching request deletes the row, releases the table, and returns
{"success":true,"deleted":true}. Requests without an identity, for an
already-consumed row, or for a replacement row return
{"success":true,"deleted":false} without deleting the current row or
releasing the table. Requires an authenticated owner, manager, cashier, or
server.
Cancel an order item.
The optional request field reason is stored with the inventory movement when
the cancellation restores stock.
- A cancellable item outside
preparingorreadybecomescancelledand restores its recorded inventory deduction. - An item in
preparingorreadybecomesvoided, adds a negativevoid_adjustmentbill line, and does not restore inventory; a manager PIN is required for the void. - A new cancellation on a completed, cancelled, paid, or partially paid order is rejected. Repeating cancellation of a terminal item is an idempotent no-op for an owner or manager.
Restore a cancelled item (owner or manager only). The item returns to pending
and its recorded inventory deduction is applied again. The request fails when
the order is terminal, the order is paid, or available stock is insufficient.
Append items to an existing order.
Headers: Authorization: Bearer <token>
For a retry-safe append, send an Idempotency-Key header containing 1–128 printable, non-whitespace ASCII characters. Reuse the same key only for the same authenticated user's identical append request (order, items, and order notes) until its response is confirmed. A matching retry returns the original 200 response without adding items again, including if the order has since become non-editable; reusing the key for different data returns 409.
Request:
{
"items": [
{
"product_id": "prod-1",
"quantity": 2,
"addons": [{ "id": "addon-1", "quantity": 1 }],
"special_instructions": "No onions"
}
],
"special_instructions": "Add drinks when ready"
}Response (200):
{
"order": { "id": "order-1", "items": [ ... ] }
}When an unpaid bill already exists for the order, appending items also
synchronizes its subtotal and recalculates its total, balance, tax, discount,
service-charge, and round-off fields.
Update item status (KDS workflow).
Headers: Authorization: Bearer <token>
Request:
{
"status": "preparing"
}Valid statuses: pending → preparing → ready → served
Both discount endpoints recalculate order totals from active order items,
including legacy items whose status is NULL; cancelled, voided,
void_adjustment, and refunded items are excluded.
Apply order-level discount.
Headers: Authorization: Bearer <token>
Request:
{
"discount_type": "percentage",
"discount_value": 10,
"discount_reason": "Happy hour"
}Validations:
discount_type: must be"percentage"or"amount"discount_value: must be positive; cannot exceed store limits (discount_max_percentage,discount_max_amount)discount_modesetting is checked — if'flat', percentage discounts are rejected; if'percentage', flat discounts are rejected- If
discount_requires_approvalis true,override_pin(manager/owner PIN) is required - Order must exist and not be completed/cancelled
Error (400):
{ "error": "Percentage discounts are disabled" }Error (403) — approval required:
{ "error": "Manager PIN required for discounts", "requiresApproval": true }Apply item-level discount.
Headers: Authorization: Bearer <token>
Request:
{
"discount_type": "amount",
"discount_value": 25,
"discount_reason": "Comp item"
}Validations: Same as order-level discount.
When an unpaid bill already exists for the order, applying an item-level
discount also synchronizes its subtotal and recalculates its total, balance,
tax, discount, service-charge, and round-off fields.
List bills.
Headers: Authorization: Bearer <token>
Query params: ?date=2025-03-31&payment_status=paid
Create bill (after order completion).
Headers: Authorization: Bearer <token>
Request:
{
"order_id": 1,
"payment_method": "cash",
"amount_tendered": 500
}Mark bill as paid.
Request:
{
"payment_method": "cash",
"amount_tendered": 500
}Apply discount to a bill (owner/manager only).
Headers: Authorization: Bearer <token>
Request:
{
"type": "percentage",
"value": 10,
"reason": "Happy hour"
}Validations:
type: must be"percentage"or"amount"value: must be positive; cannot exceed store limits (discount_max_percentage,discount_max_amount)discount_modesetting is checked — restricts which discount types are allowed- If
discount_requires_approvalis true,override_pinis required - Recalculates tax on discounted subtotal
- Updates both bill and order in a transaction
Error (400):
{ "error": "Discount exceeds maximum allowed" }Issue a full, partial, or item refund for a paid bill. Requires an authenticated owner or manager.
Headers: Authorization: Bearer <token>
Request:
{
"bill_id": 123,
"amount": 25.00,
"method": "cash",
"reason": "Customer request",
"approver_id": "owner-1",
"override_pin": "1234"
}Use order_item_id instead of amount for a whole-item refund. The request
must identify one selected approver with approver_id; manager_id is accepted
as a compatibility alias when approver_id is omitted. Missing or conflicting
approver IDs are rejected. The selected active owner's or manager's Staff
Approval PIN is verified; the device Master PIN is not used for refunds.
Refund timing and item eligibility rules are defined in
docs/business-decisions.md.
Real-time KDS connection.
Step 1: Connect to WebSocket
ws://flo.local:3001/kds
Step 2: Authenticate
{
"type": "auth",
"token": "eyJhbGciOiJIUzI1NiIs..."
}Step 3: Receive initial data
{
"type": "auth_success",
"user": {
"id": "chef-1",
"name": "Chef One",
"role": "chef",
"categoryIds": ["cat-1", "cat-2"]
},
"orders": [...],
"counts": {
"pending": 5,
"preparing": 3,
"ready": 1,
"served": 10
}
}Step 4: Receive real-time updates
{
"type": "new_order",
"order": { ... }
}{
"type": "order_updated",
"order": { ... }
}Update item status (send):
{
"type": "status_update",
"order_item_id": 1,
"status": "preparing"
}Error response:
{
"type": "auth_error",
"message": "Invalid token"
}Fetch kitchen orders (REST fallback for cloud/web).
Headers: Authorization: Bearer <token>
Query params: ?status=pending,preparing,ready,served
Response (200):
{
"orders": [...],
"counts": {
"pending": 5,
"preparing": 3,
"ready": 1,
"served": 10
}
}Search active customers for POS order linking. Requires an authenticated owner, manager, cashier, or server.
Headers: Authorization: Bearer <token>
Query params:
?q=John- Search by name or email.- Phone-like queries may include formatting characters; the digits are matched against stored phone numbers. Queries must contain at least 2 characters and return at most 20 customers as a flat array.
Response (200):
[
{
"id": "cust-1",
"name": "John Doe",
"phone": "+919876543210",
"email": "john@email.com"
}
]List customers.
Headers: Authorization: Bearer <token>
Query params: ?search=John&phone=9876543210
Create customer.
Request:
{
"name": "John Doe",
"phone": "+919876543210",
"email": "john@email.com"
}Get loyalty points.
Response:
{
"points": 150,
"last_activity": "2025-03-30"
}Earn loyalty points.
Request:
{
"points": 10,
"description": "Order #123"
}refunds.amount_cents stores integer minor units for all refunds:
- For zero-decimal currencies (e.g. JPY, KRW),
amount_centsstores whole currency units (factor 1). - For standard two-decimal currencies (e.g. USD, EUR, INR),
amount_centsstores cents (factor 100). - For three-decimal currencies (e.g. KWD, BHD, OMR),
amount_centsstores integer minor units (factor 1000).
Historical FloCafe databases operated exclusively under two-decimal currencies, where stored cents identically represent integer minor units (factor 100). Tenant business currency is configured during setup and governs store-wide order, billing, and settlement records; currency changes must not occur on active stores with open or unclosed financial periods. No database schema migration is required.
Report date parameters use tenant business dates: each YYYY-MM-DD value is
interpreted in the store's configured timezone and business-day start time.
The default start time is 00:00; a later configured start time assigns the
post-midnight interval before that time to the previous business date. Omitted
dates default to the tenant's current business date. Period fields expose the
corresponding UTC bounds where an endpoint returns them.
Daily/monthly sales report. Date query parameters use the tenant's configured store timezone and business-day start time; see the report date convention above.
Headers: Authorization: Bearer <token>
Query params: ?date=2025-03-31
Response:
{
"date": "2025-03-31",
"total_revenue": 15000,
"order_count": 45,
"avg_order_value": 333.33
}Owner-only collection summary and refund audit for a date range. Refunds are attributed to the original bill payment date so gross, refund, net, and payment-method totals reconcile for the selected period.
start_date and end_date use tenant business dates and are converted to UTC
ranges using the store timezone and configured business-day start time; see the
report date convention above.
Headers: Authorization: Bearer <owner-token>
Query params: ?start_date=2025-03-01&end_date=2025-03-31
The response includes gross and net collections, refund totals and count, bill count, average order value, payment-method totals, and up to 50 most recent refunds affecting bills collected in the range.
Live day report (cierre de caja, issue #649). Recomputes the day's aggregates on every read using the same snapshot pipeline that backs the stored Z. openingFloatCents is reported for context but is not included in expectedCashCents; the X expected figure includes cash sales, Pay In, Pay Out, Safe Drop, and cash refunds by refunds.created_at. The stored Z adds the opening float at close, so X and Z expected values differ by exactly opening_float_cents for the same day.
Role: owner, manager
Headers: Authorization: Bearer <owner-or-manager-token>
Query params: ?date=YYYY-MM-DD — tenant business date (defaults to the current business date; see the report date convention above)
Response (200):
{
"xReport": {
"businessDate": "2025-03-31",
"periodStart": "2025-03-30 18:30:00",
"periodEnd": "2025-03-31 18:30:00",
"grossCollected": 15000,
"refunded": 250,
"netCollected": 14750,
"billCount": 45,
"refundCount": 2,
"paymentMethods": [
{ "method": "cash", "count": 30, "total": 9000 },
{ "method": "card", "count": 15, "total": 6000 }
],
"staffSales": [
{ "user_id": "chef-1", "name": "Chef One", "role": "chef", "revenue": 8000, "orderCount": 22 }
],
"taxComponents": [
{ "title": "CGST", "amount": 187.5, "rate": 0.025 }
],
"openingFloatCents": 50000,
"payInCents": 12500,
"payOutCents": 5000,
"safeDropCents": 10000,
"cashMovements": [
{
"id": 101,
"business_date": "2025-03-31",
"movement_type": "pay_in",
"amount_cents": 12500,
"reason": "Petty cash returned",
"created_by": "owner-1",
"created_by_name": "Owner One",
"created_at": "2025-03-31 20:15:00",
"voided_at": null,
"voided_by": null,
"voided_by_name": null,
"void_reason": null
}
],
"expectedCashCents": 875000,
"alreadyClosed": false
}
}| Field | Type | Description |
|---|---|---|
grossCollected, refunded, netCollected, paymentMethods[].total, staffSales[].revenue, taxComponents[].amount |
number | Display major units (minor-factor-divided; matches financial-summary / tax-components). |
openingFloatCents |
integer | null | Active opening-float movement for the day, or null when none is recorded. This is reported for context and is excluded from the X expected figure. |
payInCents, payOutCents, safeDropCents |
integer | INTEGER cents. Totals of active movements for the day. |
cashMovements |
array | Active movement records for the day. Voided movements are omitted from this X-report list; use the movement-history endpoint to see append-only records and soft-void metadata. |
expectedCashCents |
integer | INTEGER cents. cash_sales + pay_ins − pay_outs − safe_drops − cash_refunds(created_at). Excludes the opening float. The consuming client must convert any counted-cash input to cents before comparing. |
businessDate / periodStart / periodEnd |
string | businessDate is a tenant business date (YYYY-MM-DD). periodStart and periodEnd are UTC bounds of that business date's configured 24-hour period, formatted YYYY-MM-DD HH:MM:SS (space-separated, no T, no Z, no millis — produced by dayBoundsInTimezone() and matching the SQLite CURRENT_TIMESTAMP family). |
alreadyClosed |
boolean | true when a cash_closures row exists for the day. |
priorClosedCashCents |
integer | null | INTEGER cents counted-cash from the most recent prior scope='day' cash_closures row (used to default the next day's opening float). null when no prior day close exists. |
priorBusinessDate |
string | null | business_date of that prior close (YYYY-MM-DD). null when no prior day close exists. |
zNumber |
integer | absent | Field is omitted from the JSON while alreadyClosed is false; present and an integer once the day is closed. |
The per-method count is the row count in the UNION'd paymentMethodBreakdown view (paid payment lines plus refund lines as negative-amount rows — paymentMethodBreakdown UNION semantics, same as financial-summary). UI labels that derive "N payments" from these counts therefore include the day's refund lines in the total; use refundCount to subtract.
The canonical "cash" identity is the literal method === 'cash' filter — custom payment-method names are not joined into the cash-only expected figure.
Convention — paid bills survive cancellation. A paid bill counts toward the day's aggregates (paymentMethods, taxComponents, grossCollected, staffSales) even when its order is later cancelled: the cash left in the drawer is real, and the X uses the same aggregator the Z uses at close. Note this is broader than the live /api/reports/tax-components endpoint, which excludes cancelled orders (main/routes/reports.ts:255-262); the X intentionally follows the Z's drawer-reality convention so the live and stored views of the same day agree. Refunds recorded against a paid bill reverse the cash via the refunds UNION in paymentMethodBreakdown.
Convention — staff and tax sections follow the paid day. The X and Z key staffSales and taxComponents by b.paid_at (the day cash was collected) so every section of the immutable Z reconciles to the same window as grossCollected, paymentMethods, and expectedCashCents. On a cross-midnight day (order created Day 1, paid Day 2), staff revenue and tax components land in Day 2's snapshot. This differs from /api/reports/insights (topStaff, keyed by orders.created_at) and /api/reports/tax-components (keyed by bills.created_at) on cross-midnight days; the divergence is intentional — the Z must be internally reconcilable, while those live views prioritize the order's creation day. staffSales[].orderCount counts paid bills (not orders), so split checks multiply it; /api/reports/insights topStaff.orderCount counts orders instead.
Stored day-close snapshot. Reads the immutable cash_closures row for the requested business date. The stored Z is the closed day's authoritative figure — late refunds against a closed day keep their existing live-report behaviour but never rewrite the row. No reopen endpoint exists in v1; corrections are operator notes, not mutations.
Role: owner, manager
Headers: Authorization: Bearer <owner-or-manager-token>
Query params: ?date=YYYY-MM-DD — tenant business date (defaults to the current business date; see the report date convention above)
Response (200):
{
"zReport": {
"id": 17,
"scope": "day",
"business_date": "2025-03-31",
"period_start": "2025-03-30 18:30:00",
"period_end": "2025-03-31 18:30:00",
"opening_float_cents": 50000,
"expected_cash_cents": 925000,
"counted_cash_cents": 925000,
"variance_cents": 0,
"gross_collected_cents": 1500000,
"refunded_cents": 25000,
"net_collected_cents": 1475000,
"bill_count": 45,
"refund_count": 2,
"payment_methods": [
{ "method": "cash", "count": 30, "total_cents": 900000 },
{ "method": "card", "count": 15, "total_cents": 600000 }
],
"staff_sales": [
{ "user_id": "chef-1", "name": "Chef One", "role": "chef", "revenue_cents": 800000, "orderCount": 22 }
],
"tax_components": [
{ "title": "CGST", "amount": 187.5, "rate": 0.025 }
],
"pay_in_cents": 12500,
"pay_out_cents": 5000,
"safe_drop_cents": 10000,
"cash_movements": [
{
"id": 101,
"business_date": "2025-03-31",
"movement_type": "pay_in",
"amount_cents": 12500,
"reason": "Petty cash returned",
"created_by": "owner-1",
"created_by_name": "Owner One",
"created_at": "2025-03-31 20:15:00",
"voided_at": null,
"voided_by": null,
"voided_by_name": null,
"void_reason": null
}
],
"z_number": 17,
"closed_by": "owner-1",
"notes": null,
"created_at": "2025-04-01 01:23:45"
}
}| Field | Type | Description |
|---|---|---|
All *_cents fields |
integer | INTEGER cents. expected_cash_cents includes the opening float and active movements: expected = opening_float + cash_sales + pay_ins − pay_outs − safe_drops − cash_refunds(created_at). The same-day X and Z expected values therefore differ by exactly opening_float_cents — consumers must not compare them directly. |
cash_movements |
array | Active movement records captured in the immutable close snapshot, including opening float, Pay In, Pay Out, and Safe Drop entries. |
payment_methods[].total_cents, staff_sales[].revenue_cents |
integer | INTEGER cents (storage shape; converted to display major units at the X read edge). |
tax_components[].amount |
number | Display major units — identical to the X response's taxComponents, not cents. The Z stores the same aggregateTaxComponents output verbatim and serves it without conversion. |
variance_cents |
integer | counted_cash_cents − expected_cash_cents. May be negative. |
z_number |
integer | Monotonic, allocated from nextZNumber() at close time. |
closed_by |
string | users.id of the operator who closed. |
closed_by_name |
string | Display name of that operator (users.name), with closed_by used as fallback when the user row is missing. Resolved server-side on read for the Z JSON and on print for the receipt body. |
notes |
string | null | Free-form operator notes from the close request, or null if none were provided. |
created_at |
string | UTC close timestamp, formatted YYYY-MM-DD HH:MM:SS (space-separated, no T, no Z, no millis — matches db.now() and SQLite CURRENT_TIMESTAMP). |
business_date / period_start / period_end |
string | business_date is a tenant business date (YYYY-MM-DD). period_start and period_end are UTC bounds of that business date's configured 24-hour period, formatted YYYY-MM-DD HH:MM:SS (space-separated, no T, no Z, no millis — produced by dayBoundsInTimezone() and matching the SQLite CURRENT_TIMESTAMP family). |
Error (404): the day is not yet closed.
{ "error": "Day not closed", "alreadyClosed": false, "businessDate": "2025-03-31" }List the append-only cash-drawer movement history for a tenant business date. The response is ordered newest first and includes opening float, Pay In, Pay Out, and Safe Drop records, including soft-voided records with their audit metadata.
Role: owner, manager, cashier
Headers: Authorization: Bearer <owner-manager-or-cashier-token>
Query params: ?business_date=YYYY-MM-DD — tenant business date.
Response (200):
{
"businessDate": "2025-03-31",
"movements": [
{
"id": 101,
"business_date": "2025-03-31",
"movement_type": "pay_in",
"amount_cents": 12500,
"reason": "Petty cash returned",
"created_by": "owner-1",
"created_by_name": "Owner One",
"created_at": "2025-03-31 20:15:00",
"voided_at": null,
"voided_by": null,
"voided_by_name": null,
"void_reason": null
}
]
}movement_type is one of opening_float, pay_in, pay_out, or safe_drop. All amount_cents values are non-negative integer cents.
Append a cash-drawer movement to an open tenant business date. Movement rows are never updated or deleted by this endpoint. Only one active opening_float may exist for a business date.
Role: owner, manager, cashier
Headers: Authorization: Bearer <owner-manager-or-cashier-token>
Request:
{
"business_date": "2025-03-31",
"movement_type": "pay_in",
"amount_cents": 12500,
"reason": "Petty cash returned"
}| Field | Type | Description |
|---|---|---|
business_date |
string | Tenant business date (YYYY-MM-DD). Must be a real, non-future calendar date. |
movement_type |
string | opening_float, pay_in, pay_out, or safe_drop. |
amount_cents |
integer | Non-negative integer cents for opening_float; positive integer cents for other movement types. |
reason |
string | optional | Optional for opening_float; required for other movement types. Maximum 500 characters. |
Response (201): { "movement": { ...movement fields... } }
Errors: 400 for invalid input, 409 when the day is already closed or an active opening float already exists for the date.
Soft-void an existing cash movement. The original row remains in history with voided_at, voided_by, and void_reason populated, and no new movement row is created.
Role: owner, manager
Headers: Authorization: Bearer <owner-or-manager-token>
Path params: :id — positive integer movement ID.
Request:
{ "reason": "Entered on the wrong business date" }Response (200): { "movement": { ...movement fields with void metadata... } }
Errors: 400 for an invalid ID or missing/overlong reason, 404 when the movement does not exist, or 409 when the movement is already voided or its business date is already closed.
Close the current tenant business day (cierre de caja, issue #649). One close per business date per store. The backend recomputes every aggregate server-side — it never trusts client totals — and stores one immutable row in cash_closures inside a single transaction. The stored Z is the closed day's authoritative figure; no reopen endpoint exists in v1.
Role: owner (manager / cashier / server → 403)
Headers: Authorization: Bearer <owner-token>
Request:
{
"business_date": "2025-03-31",
"opening_float_cents": 50000,
"counted_cash_cents": 925000,
"notes": "Late drawer count"
}| Field | Type | Description |
|---|---|---|
business_date |
string | Tenant business date (YYYY-MM-DD). Must be a real calendar date (regex match is not enough — 2026-02-30 is rejected). Not in the future relative to the current tenant business date. |
opening_float_cents |
integer | INTEGER cents, >= 0. Cash float the operator is starting the day with. |
counted_cash_cents |
integer | INTEGER cents, >= 0. Cash the operator counted in the drawer at close. |
notes |
string | optional | Free-form notes, ≤ 500 characters. |
Snapshot math (verbatim from spec):
expected_cash_cents = opening_float_cents
+ cash_sales_cents
+ pay_in_cents
− pay_out_cents
− safe_drop_cents
− cash_refunds_by_created_at_cents
variance_cents = counted_cash_cents − expected_cash_cents
The canonical "cash" identity is the literal method === 'cash' filter — custom payment-method names are not joined into the cash-only expected figure. Refunds are attributed by refunds.created_at for the drawer-reality split; display totals attribute refunds to the original bill's paid_at (matching financial-summary).
Response (201):
{
"zReport": {
"id": 17,
"scope": "day",
"business_date": "2025-03-31",
"period_start": "2025-03-30 18:30:00",
"period_end": "2025-03-31 18:30:00",
"opening_float_cents": 50000,
"expected_cash_cents": 925000,
"counted_cash_cents": 925000,
"variance_cents": 0,
"gross_collected_cents": 1500000,
"refunded_cents": 25000,
"net_collected_cents": 1475000,
"bill_count": 45,
"refund_count": 2,
"payment_methods": [
{ "method": "cash", "count": 30, "total_cents": 900000 },
{ "method": "card", "count": 15, "total_cents": 600000 }
],
"staff_sales": [
{ "user_id": "chef-1", "name": "Chef One", "role": "chef", "revenue_cents": 800000, "orderCount": 22 }
],
"tax_components": [
{ "title": "CGST", "amount": 187.5, "rate": 0.025 }
],
"pay_in_cents": 12500,
"pay_out_cents": 5000,
"safe_drop_cents": 10000,
"cash_movements": [
{
"id": 101,
"business_date": "2025-03-31",
"movement_type": "pay_in",
"amount_cents": 12500,
"reason": "Petty cash returned",
"created_by": "owner-1",
"created_by_name": "Owner One",
"created_at": "2025-03-31 20:15:00",
"voided_at": null,
"voided_by": null,
"voided_by_name": null,
"void_reason": null
}
],
"z_number": 17,
"closed_by": "owner-1",
"notes": "Late drawer count",
"created_at": "2025-04-01 01:23:45"
}
}The response field shape matches GET /api/reports/z-report except it omits closed_by_name, which is resolved server-side on the Z read — the stored row is the source of truth for both reads.
Error (400): malformed body, non-integer / negative cents, future date, notes too long, or invalid calendar date. The error message names the offending field, e.g. business_date is not a real calendar date, counted_cash_cents must be >= 0, notes is too long.
Error (409): the day is already closed (duplicate POST against the same business_date).
{ "error": "This day is already closed" }A concurrent winner of a double-POST race still returns 409 — the partial unique index cash_closures_one_day ... WHERE scope = 'day' is the safety net behind the SELECT-then-INSERT.
Dispatch the stored Z to the default receipt printer. The forced drawer pulse is appended server-side (bypassing bill-bound shouldPulseForPayment, which can never fire for a bill-less Z) and is not filtered through cash_drawer_pulse_methods: the Z is the document the merchant prints while counting the drawer. The stored row is never mutated by printing.
Role: owner (manager / cashier / server → 403)
Headers: Authorization: Bearer <owner-token>
Path params: :id — positive integer, the cash_closures.id returned by POST /api/cash-closures or GET /api/reports/z-report.
Request:
{ "isReprint": false }| Field | Type | Description |
|---|---|---|
isReprint |
boolean | optional | When true, the printed body shows a REPRINT marker next to the Z number. Defaults to false. |
The route resolves the default receipt printer server-side (the WebUSB branch is reachable end-to-end this way; helpers that exclude WebUSB would otherwise skip it). Labels use the independently configured z_report_language_policy, which defaults to the store language and supports one additional language.
Response (200, WebUSB): the renderer dispatches the bytes itself.
{ "success": true, "webusb": true, "isReprint": false, "bytes": [27, 64, 27, 112, 0, 25, 250], "warnings": [] }Response (200, network / USB):
{ "success": true, "isReprint": false, "warnings": [] }Error (400): invalid id.
{ "error": "id must be a positive integer" }Error (404): no row for that id.
{ "error": "Cash closure not found" }Error (409): no default printer is configured.
{ "error": "No default printer configured" }Error (502): the printer did not respond or the dispatch failed.
{ "error": "<detail>", "detail": "<detail>", "warnings": [] }Unsupported text in a financial Z-report unit fails closed before dispatch and
returns a financial warning in the 502 response. Non-financial skipped text is
reported in warnings without claiming that it printed.
Printed Z layout, in spec order: header (business name, address, tax id — branch omitted: no branch data source exists in the schema) → Z number + business date + period start/end → opening float → cash movements (Pay In, Pay Out, Safe Drop) → sales by payment method → refunds → tax breakdown → staff sales → expected / counted / variance (variance emphasized) → operator + signature line → footer. The forced drawer pulse is appended after the footer.
Get business settings. Locale display preferences (currency_display, number_digits, calendar) are resolved against the active country's declared localeOptions; stale or unsupported stored values are normalized to neutral defaults.
Response:
{
"business_name": "My Restaurant",
"timezone": "Asia/Kolkata",
"business_day_start_time": "00:00",
"currency": "INR",
"country": "IN",
"tax_registration_number": "22AAAAA0000A1Z5",
"currency_display": "rial",
"number_digits": "locale",
"calendar": "locale"
}Update business settings.
timezone is validated as an IANA identifier; invalid values return HTTP 400 with "Invalid timezone, currency, or country".
business_day_start_time configures the local start of the 24-hour business
period used by reports and cash closures. It is returned as HH:mm, defaults
to 00:00, and is trimmed before persistence; invalid values return HTTP 400.
currency accepts any three-letter ASCII currency code. Leading/trailing
whitespace is trimmed and lowercase input is normalized to uppercase before
the value is persisted; invalid codes return the same HTTP 400 response.
When tax_registration_number is provided, the backend validates it against the active country pack's registration format. A mismatch returns HTTP 400:
{
"error": "Tax ID does not match the expected IN format: 15-digit GSTIN",
"tax_id_format": { "pattern": "...", "description": "..." }
}Locale display preferences (currency_display, number_digits, calendar) are validated against the effective country's localeOptions. Unsupported values return HTTP 400 with "Invalid <key> for country <code>". Changing the country normalizes any previously stored preferences that are not supported by the new country to their neutral defaults (rial, locale, locale).
Get tax settings.
Update tax settings (owner/manager only).
Validates tax_registration_number against the active country pack format, same as PUT /api/settings/business.
Get discount limits configuration.
Headers: Authorization: Bearer <token>
Response (200):
{
"discount_max_percentage": 50,
"discount_max_amount": 100,
"discount_mode": "both",
"discount_requires_approval": false
}| Field | Type | Description |
|---|---|---|
discount_max_percentage |
number | Max % for percentage discounts (0 = no limit) |
discount_max_amount |
number | Max flat amount for discounts (0 = no limit) |
discount_mode |
string | 'percentage', 'flat', or 'both' — which discount types are allowed |
discount_requires_approval |
boolean | Require manager PIN to apply discounts |
Update discount limits (owner/manager only).
Headers: Authorization: Bearer <token>
Request:
{
"discount_max_percentage": 30,
"discount_max_amount": 200,
"discount_mode": "both",
"discount_requires_approval": true
}Validation:
discount_max_percentage: float, range 0–100 (0 = no limit)discount_max_amount: float, range 0–999999 (0 = no limit)discount_mode: must be'percentage','flat', or'both'discount_requires_approval: boolean
Error (400):
{ "error": "discount_mode must be \"percentage\", \"flat\", or \"both\"" }Printer configuration is available to owners and managers. Receipt and KOT print endpoints also allow cashiers. See Printer setup for the operational guide.
List configured printers, with their resolved printer profile.
Detect available USB and network printers.
List FloCafe's known printer profiles.
Get one configured printer.
Create a printer. connection_type must be network, usb, or webusb. Network printers require ip_address.
{
"name": "Kitchen Printer",
"connection_type": "network",
"ip_address": "192.168.1.100",
"port": 9100,
"paper_width": "80mm",
"cash_drawer_pulse_enabled": false,
"is_default": true
}A WebUSB entry stores the paper-width preference; the browser selects the physical device.
Update printer configuration. The request accepts the same fields as creation.
Delete a configured printer.
Make a printer the default for regular receipt printing.
Send a test page. The printed timestamp uses the tenant's configured store
timezone. Pass { "rasterProbe": true } to request the capability-gated
raster diagnostic bands; profiles without enabled raster capability retain the
standard test page. For WebUSB, the response contains the ESC/POS bytes for the
browser to send.
Print the bill identified by billId or the bill associated with orderId.
{
"billId": 123,
"useUnicode": false,
"isReprint": false,
"preview": false,
"arabicShaping": false
}Pass preview: true to generate receipt preview text, base64 ESC/POS payload, and column metrics without dispatching to a physical printer. If no hardware printer is configured, preview mode falls back to default 80 mm formatting.
Successful print responses include { "success": true, "warnings": [] }. If
receipt preparation finds unsupported financial text, the endpoint returns HTTP
502 before transport with stage: "prepare", failure_class: "unsupported",
and the financial warnings in warnings; dispatch failures use
stage: "dispatch". See printing architecture warning semantics for the warning contract.
Print a kitchen order ticket for orderId. A caller may provide stationName and items; otherwise FloCafe routes items to configured kitchen stations. The KOT status-filtering contract is defined in printing architecture. This endpoint returns 403 when KOT printing is disabled.
{
"orderId": 123,
"useUnicode": false,
"arabicShaping": false
}Owner-role CRUD for tenant-owned semantic receipt templates (#447). See Merchant print templates for the payload schema, validation policy, provenance/trust model, and offline transfer contract. Payloads are validated fail-closed on every write.
List merchant templates (all statuses). Owner or manager.
Create a template in draft status. Body: { name, payload, origin?, derivedFrom? }.
origin is one of created | imported | cloned; cloned requires a
derivedFrom reference { type: 'compliance-pack-template' | 'merchant-template' | 'offline-import', templateId }
(informational only — no compliance trust transfers).
Update name and/or payload. Editing an ACTIVE template snapshots its current payload into the single-step rollback point. Archived templates are immutable.
Promote to active. Fails closed (409) if the stored checksum does not match
the payload.
Terminal state; archived templates stop being selectable.
Restore previous_payload_json after verifying the current checksum; clears
the rollback point. 409 when there is nothing to roll back to, when the
restored payload fails current validation, or when the template is archived.
Read the stored payload (owner or manager).
Download an active or archived template as a portable
*.flocafe-template.json envelope. Owner only. Drafts and rows with invalid
stored checksums are rejected; the response is JSON with an attachment
filename.
Import a portable envelope as a new draft. Owner only. Body:
{ file, name?, fileName? }, where file is the raw envelope JSON text and
fileName is the optional source filename recorded in offline-import
provenance. The import path enforces the envelope and payload validators,
checksum verification, and the 256 KB raw-byte cap; it never activates or
overwrites an existing template.
Get current pairing code.
Response:
{
"pairing_code": "123456",
"rotated_at": "2025-03-31T10:00:00Z"
}Generate new pairing code.
Get KDS access URLs and QR code.
Response:
{
"mdns_url": "http://flo.local:3001/kds",
"ip_url": "http://192.168.1.50:3001/kds",
"qr_url": "http://192.168.1.50:3001/kds",
"qr_data_url": "data:image/png;base64,..."
}| Event | Direction | Description |
|---|---|---|
auth |
→ Server | Authenticate with JWT token |
auth_success |
← Server | Authentication successful |
auth_error |
← Server | Authentication failed |
initial_data |
← Server | Initial orders and counts |
new_order |
← Server | New order created |
order_updated |
← Server | Order status changed |
status_update |
→ Server | Update item status |
orders |
← Server | Full orders list (periodic) |
The order-level transition matrix is documented with
PATCH /api/orders/:id/status above. The KDS item progression is
pending → preparing → ready → served; cancellation and void statuses
are documented in the Order Items section.
Each item in an order has its own status, allowing:
- Multiple items in one order
- Different items at different stages
- KDS shows items filtered by status
See Roles and permissions for the complete current role matrix. The database accepts owner, manager, cashier, server, and chef; the historical waiter label is no longer a valid role.
Users with chef role have category_ids array. When accessing KDS:
- Server validates JWT token
- Server checks role is
chef,manager, orowner - Server filters order items to only show products in user's categories
- One user can have multiple categories
Example: Chef1 (cat-1, cat-2) only sees Food and Beverages items.