Reference / Restaurant

Restaurant

Run a multi-branch restaurant from one company: the expense ledger with VAT breakdown, due-date reminders, daily income against the Z-report, the menu with shared option groups, table orders, and the board your counter staff watches.

Money and VAT#

Two conventions that will silently corrupt your numbers if you guess them. Read this section before writing any amount.

Every amount is an integer in minor units grossMinor: 12550 means ₺125,50. Never send a decimal: floats cannot represent money exactly, and a half-cent drift compounds across a month of orders into a total that no longer reconciles with the till.

VAT rates are basis points vatRateBps: 2000 is 20%, 800 is 8%, 150 is 1,5%. Basis points (rather than a percent integer) exist so rates like 13,5% or 8,1% are expressible; a fraction like 0.2 was rejected because float equality breaks grouping in reports.

Amounts you send are VAT-inclusive. The server splits them itself: send grossMinor and vatRateBps and it derives netMinor and vatMinor. If you send a breakdown of your own it is ignored — the split has to be reproducible from the stored gross, otherwise two clients rounding differently would produce two different VAT totals for the same receipt.

Business date vs calendar date#

The app deliberately uses two different notions of `day`. Sending one where the other belongs puts records in the wrong period.

Business date is what operations use — sales, shifts and orders. A branch has a dayCutoffHour (default 4), so a sale rung at 02:30 belongs to the previous day's takings, which is how the staff closing the till think about it. Fields named businessDate take this.

Calendar date is what obligations use — due dates and payment dates. A rent invoice due on the 1st is due on the 1st no matter when the kitchen closes. Fields named accrualDate, dueDate and paidAt take this.

Both are written as YYYY-MM-DD day keys, so nothing in the payload tells you which one an endpoint wants — the field name does.

Branches#

Branches are sub-units of one legal entity, not separate companies: they share the menu, the tax identity and the reporting currency. Almost every other resource is filtered by branchId.

List branches#

GET/api/restaurant/companies/{slug}/branches?activeOnly=true
const res = await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/branches?activeOnly=true",
  { headers: { Authorization: `Bearer ${token}` } },
)
const { data } = await res.json()
// → [{ id, name, address, dayCutoffHour, order, active }]

Create a branch#

POST/api/restaurant/companies/{slug}/branches
const res = await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/branches",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "Kadıköy",
      dayCutoffHour: 4,
    }),
  },
)
NameTypeDescription
namestringBranch name. Required.
addressstringOptional free-text address.
dayCutoffHournumberHour (0–23) at which the business day rolls over. Default 4 — a 02:30 sale counts toward the previous day.
ordernumberSort order in pickers.
activebooleanInactive branches stay in history but disappear from pickers.

Expenses#

The expense ledger — the core of the finance side. Every expense belongs to exactly one branch and one category, optionally a vendor.

Record an expense#

POST/api/restaurant/companies/{slug}/expenses
await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/expenses",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      branchId,
      categoryId,
      grossMinor: 125500, // ₺1.255,00 — VAT included
      vatRateBps: 2000,   // 20%
      accrualDate: "2026-08-20",
    }),
  },
)
NameTypeDescription
branchIdstringRequired. The caller must be allowed to write to this branch.
categoryIdstringRequired. Must belong to the same company.
vendorIdstring | nullOptional supplier.
grossMinornumberRequired. VAT-inclusive amount in minor units.
vatRateBpsnumberRequired. Basis points — 2000 = 20%.
accrualDateYYYY-MM-DDRequired. Calendar date the cost belongs to.
dueDateYYYY-MM-DD | nullSetting this creates a payable and enables reminders.
paidAtISO datetime | nullOmit for an unpaid expense; the payables flow fills it in.
paymentMethodstringDefaults to cash.
isVatDeductiblebooleanDefaults to true. Non-deductible VAT stays in the cost figure.
receiptUrlstring | nullPhoto of the receipt — upload via Storage, store the URL here.

Bulk import from CSV#

Send the CSV either as application/json with a csv field, or as a raw text/csv body with the options in the query string — the dashboard reads the file and posts JSON, while a cURL user would rather POST the file directly.

The import reports per-row outcomes instead of failing the file. A 1000-row file with three bad rows imports 997 and names the three, because rejecting the whole file leaves the operator hunting for the problem with no help from you. Send dryRun first to see what would happen without writing.

POST/api/restaurant/companies/{slug}/expenses/import
await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/expenses/import",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ csv, branchId, dryRun: true }),
  },
)
// → { ok, failed, total, dryRun }

// Or post the file itself:
//   Content-Type: text/csv
//   /expenses/import?branchId=…&dryRun=1

Payables and reminders#

An expense with a dueDate becomes a payable. The upcoming endpoint is what the reminder screen and the notification sweep both read.

Upcoming payments#

Results come back grouped as overdue, thisWeek, thisMonth and later— the grouping is server-side so every client (dashboard, mobile, the reminder job) draws the same line between "late" and "soon".

GET/api/restaurant/companies/{slug}/payables/upcoming?branchId={id}
const res = await fetch(
  `https://sentroy.com/api/restaurant/companies/my-company/payables/upcoming?branchId=${branchId}`,
  { headers: { Authorization: `Bearer ${token}` } },
)
// → { groups: [{ key: "overdue", items: [...], totalMinor }] }

Mark a payable as paid#

POST/api/restaurant/companies/{slug}/payables/{id}/pay
await fetch(
  `https://sentroy.com/api/restaurant/companies/my-company/payables/${id}/pay`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ amountMinor: 125500, paymentMethod: "transfer" }),
  },
)

Daily income and Z-report reconciliation#

One row per branch × business day × channel. Writes are upserts, so re-sending the same day is safe.

Record a day's takings#

businessDatecannot be in the future relative to the branch's own cutoff — a branch whose day rolls at 04:00 can still write "today" at 01:00. Send zReportMinorwhen you have the till's own total and the reconciliation screen will show the difference instead of silently trusting either number.

POST/api/restaurant/companies/{slug}/income
await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/income",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      branchId,
      businessDate: "2026-08-20",
      channel: "dine_in",
      grossMinor: 4_820_000,
    }),
  },
)
NameTypeDescription
channelstringSales channel (dine-in, takeaway, a delivery platform…). Each channel is its own row.
grossMinornumberWhat the customer paid, VAT included.
commissionMinornumberPlatform commission withheld. Cannot exceed grossMinor.
zReportMinornumber | nullThe till's own total for the same day, for reconciliation.

Tables and orders#

An open order is a running tab on a table. Waiters add lines from a tablet; the counter watches the board.

Open an order#

clientOrderId is required and is your own idempotency key. A tablet on a flaky connection retries; without the key the retry opens a second tab on the same table. The server looks the key up before allocating an order number, so a retry returns the original order rather than burning a number.

POST/api/restaurant/companies/{slug}/orders
await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/orders",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      branchId,
      tableId,
      clientOrderId: crypto.randomUUID(), // keep it across retries
      items: [{ productId, quantity: 2 }],
    }),
  },
)

Add or remove lines (optimistic locking)#

Line edits carry the order's version. If someone else changed the tab first you get 409 with the currentorder in the body, so the client can redraw and let the waiter decide — rather than overwriting a colleague's line silently. lineId and version travel in the query string on delete, because some HTTP stacks drop a DELETE body.

POST/api/restaurant/companies/{slug}/orders/{id}/items
const res = await post(`/orders/${id}/items`, {
  version: order.version,
  productId,
  quantity: 1,
})
if (res.status === 409) {
  // body carries the current order — redraw, don't retry blindly
}

Prices, names and VAT rates are snapshottedonto the line when it is added. Tomorrow's price rise does not rewrite yesterday's bill.

Payments#

An order can take several payments (payments[]), which covers a table paying partly in cash and partly by card. Splitting the bill per item is not in this version.

POST/api/restaurant/companies/{slug}/orders/{id}/payments
// Keep the same paymentId across retries of the SAME payment.
await post(`/orders/${id}/payments`, {
  paymentId,
  amountMinor: 24_000,
  method: "card",
})

Pending orders board#

The screen the counter keeps open — this app has no receipt printer, so the board is the kitchen ticket. branchId is required; businessDate defaults to the branch's current business day.

GET/api/restaurant/companies/{slug}/orders/board?branchId={id}
const res = await fetch(
  `https://sentroy.com/api/restaurant/companies/my-company/orders/board?branchId=${branchId}`,
  { headers: { Authorization: `Bearer ${token}` } },
)

Shifts and cash count#

A shift opens with a float and closes with a count, so the drawer can be checked against what the orders say should be in it.

POST/api/restaurant/companies/{slug}/shifts
await post("/shifts", { branchId, openingFloatMinor: 50_000 })
// GET /shifts/current?branchId=… → the open shift, or null

A branch can only have oneopen shift at a time. Opening a second one fails rather than silently splitting the evening's cash across two records.

Reports#

Monthly totals with branch, category and vendor breakdowns, plus the accrual-vs-cash switch.

basis picks how a cost is dated: accrual counts it in the month it was incurred, cash in the month it was actually paid. The same ledger produces two legitimately different monthly figures, which is why the switch is explicit rather than a default someone has to guess.

GET/api/restaurant/companies/{slug}/reports?from=2026-08-01&to=2026-08-31&basis=accrual
const res = await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/reports" +
    "?from=2026-08-01&to=2026-08-31&basis=accrual&topVendors=10",
  { headers: { Authorization: `Bearer ${token}` } },
)
NameTypeDescription
from / toYYYY-MM-DDReporting window.
basisaccrual | cashWhich date decides the period a cost lands in.
branchIdstringOmit for all branches the caller can see.
topVendorsnumberHow many suppliers to break out.
foodCategoryIdscomma-separated idsWhich categories count as food cost for the ratio.

GET /reports/export returns the same package as CSV. The export is written to the audit log while reading a report is not — a file leaving the building is worth a record; re-filtering a screen thirty times would only make the audit log unreadable.

Overview#

One call for the landing screen: today's takings, open tabs, what is overdue, and the month so far.

GET/api/restaurant/companies/{slug}/overview?branchId={id}
const res = await fetch(
  "https://sentroy.com/api/restaurant/companies/my-company/overview",
  { headers: { Authorization: `Bearer ${token}` } },
)

Errors#

Failures return a stable machine-readable code, not a sentence.

The body is { "error": "<code>" } and the code is drawn from a fixed set — branch_not_found, forbidden_branch, vat_rate_invalid, invalid_amount, business_date_in_future, order_not_open, invalid_version, commission_exceeds_gross and so on. Codes are stable and are what the dashboard translates into five languages; match on the code, never on prose.

Two conflict cases share 409 on the order endpoints and mean different things: invalid_versionis "the tab moved under you, re-read it", while order_not_openis "this tab is closed, stop writing". A closed tab returns 409 rather than 400 because the request was valid — it just lost a race.