Files
jtlPosSync/API.md
2026-07-27 01:14:36 +02:00

500 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# JTL POS Sync API
HTTPS API that mirrors the JTL-POS ↔ Wawi sync protocol. Use this to build a custom POS client that syncs catalog data and submits orders.
Two servers implement this protocol:
| Implementation | Entry | Handlers |
|---|---|---|
| **Node.js** | `server.js` | [`src/endpoints/`](src/endpoints/) |
| **C++** | `jtlsrv-cpp/` | [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/) |
Clients talk to either over the same paths and shapes. See [Node vs C++](#node-vs-c) for the few behavioral differences.
Base URL: `https://<host>:<port>` (default port `4443`)
Paths may be called as `/v1/...` or `/api/v1/...` — both resolve to the same handlers.
All JSON responses use `Content-Type: application/json; charset=utf-8`. Numeric fields are usually returned as **strings**.
The server currently does **not** validate the `authToken` on subsequent requests after pairing. Pairing still issues a token so clients can store it for a real JTL-compatible flow.
---
## Client lifecycle
```
1. Pair GET /v1/client
2. Poll sync GET /v1/init (with lastChanged* cursors)
3. Fetch deltas GET /v1/category|product|productcomposite|customergroup|deletedentity
4. Fetch images GET /v1/pimage|cimage (optional, by hash)
5. Submit sale POST /v1/order
```
Persist every `lastChanged*` cursor locally. On the next poll, send the highest value you have seen for that entity type so you only download deltas.
See [productSync.md](productSync.md) for the cursor / row-version model in detail.
---
## Demo mode
Node can serve a generated in-memory catalog without MSSQL.
1. Generate artifacts once: `npm run demo:generate`
Writes `demo/catalog.json` and `demo/images/<hash>.jpg` (≥1000 products, 34 category levels, variants, real photos).
2. Set `DEMO_MODE=true` in `.env` (see `.env.example`).
3. Start the server as usual (`npm start`). Pairing and all sync endpoints work; orders are logged and return synthetic `OK` results.
Demo mode is **opt-in only** — a failed MSSQL connection does not enable it.
---
## `GET /v1/client` — Pairing
Discovers the server and completes pairing with a 6-digit code.
### Query parameters
| Param | Required | Description |
|---|---|---|
| `authCode` | yes | Pairing code. Short codes (14 chars) trigger discovery; a 6-digit code completes pairing. |
| `name` | no | Device name stored on successful pair. Default: `JTL-POS`. |
### Flow
1. **Discovery** — call with a short `authCode` (length 14). Response includes `authToken`, certificate fingerprints, and `mandantId`. `mandantName` / `mandantDatabase` are `null`.
2. **Pair** — call with the 6-digit pairing code. On success the code is revoked and the device is registered. Response includes `mandantName` and `mandantDatabase`.
### Success (200)
```json
{
"authCode": "307018",
"authToken": "df40ad2067954646abb0499548a52241",
"certificateFingerprint": "...",
"certificateSerialNumber": "...",
"mandantId": "1",
"mandantName": "eB-Standard",
"mandantDatabase": "eazybusiness",
"serverFingerprint": null,
"name": null,
"serverTimestamp": "2026-07-20 15:00:00"
}
```
### Errors
| Status | When |
|---|---|
| 400 | Wrong 6-digit code (`Der Authentifizierungscode ist falsch.`) |
| 400 | Missing / invalid code (`Keinen passenden Authentifizierungscode gefunden.`) |
---
## `GET /v1/init` — Sync status
Returns how many entities changed since each cursor. Poll this; only fetch list endpoints when the matching `*_count` is greater than `"0"`.
### Query parameters
| Param | Default | Cursor for |
|---|---|---|
| `lastChangedProduct` | `0` | Products |
| `lastChangedCategory` | `0` | Categories |
| `lastChangedCustomerGroup` | `0` | Customer groups |
| `lastChangedCompositeProduct` | `0` | Composite (Stückliste) products |
| `lastChangedDeletedEntity` | `0` | Deleted entities |
### Response (200)
```json
{
"version": "1.10.12.0",
"product_count": "3",
"category_count": "0",
"customer_count": "0",
"customerGroup_count": "1",
"compositeProduct_count": "0",
"configurationGroup_count": "0",
"configurationItem_count": "0",
"deletedEntity_count": "0",
"max_orderId_count": "100"
}
```
| Field | Meaning |
|---|---|
| `*_count` | Number of rows with `lastChanged` **strictly greater than** the matching cursor |
| `max_orderId_count` | Highest POS order id already mapped for this shop (`Pos.tAuftragMapping`) |
| `customer_count`, `configurationGroup_count`, `configurationItem_count` | Always `"0"` (not implemented) |
---
## Catalog sync endpoints
Shared pattern for list endpoints:
1. Call with the same cursor used on `/v1/init`.
2. Results are ordered by `lastChanged` ascending.
3. After each page, set the cursor to the highest `lastChanged` in the batch.
4. Repeat until `/v1/init` reports a count of `"0"`.
### `GET /v1/category`
| Param | Default |
|---|---|
| `lastChangedCategory` | `0` |
| `limit` | `20` |
```json
[
{
"_id": "12",
"imghash": "abc...",
"imgsrc": "abc...",
"name": "Beverages",
"pid": "0",
"discounts": [],
"sort": "10",
"lastChanged": "24001",
"updated_at": "2026-07-20 15:00:00",
"created_at": "2026-07-20 15:00:00"
}
]
```
- `pid` is `"0"` for top-level categories (under the shop root).
- `imghash` / `imgsrc` are image content hashes for `/v1/cimage`.
### `GET /v1/product`
| Param | Default |
|---|---|
| `lastChangedProduct` | `0` |
| `limit` | `20` |
```json
[
{
"_id": "1234",
"sku": "ART-001",
"barcode": "4006381333931",
"name": "Example product",
"tax_rate": "19",
"price": "11.90",
"created_at": "2025-01-15 10:00:00",
"lastChanged": "24609",
"categories_id": "12",
"categories": [{ "categoryId": "12" }],
"prices": [
{ "customerGroupId": "1", "customerId": "0", "price": "11.90", "quantity": "0" }
],
"imghash": "def...",
"imgsrc": "def...",
"is_parent": "0",
"parent": "0",
"variants": "",
"isCompositeProduct": "0",
"attributes": [
{ "aname": "Color", "aprice": "0.0", "asort": "1", "atype": "1", "agroup": "JTL-POS" }
],
"deposit": "0",
"deposit_name": "",
"d_price": "0.0"
}
]
```
Notable fields:
| Field | Notes |
|---|---|
| `price` / `prices[].price` | Gross prices (net × tax) |
| `prices` | One entry per customer group |
| `isCompositeProduct` | `"1"` if the article is a Stückliste parent |
| `imghash` | Pass to `/v1/pimage?path=...` |
| Deposit fields | Present when JTL-POS Pfand attributes are set |
| `attributes` | Article attributes from `tArtikelAttribut` (incl. Pfand) |
Many other product fields are filled with static defaults (`sort`, `use_stock`, `unit`, etc.) for JTL-POS compatibility.
**Node only:** `prices[]` applies percustomer-group net overrides from `tPreis` / `tPreisDetail`. C++ currently fills every group with the base gross price.
### `GET /v1/productcomposite`
BOM rows for composite (Stückliste) parents.
| Param | Default |
|---|---|
| `lastChangedCompositeProduct` | `0` |
| `limit` | `100` |
```json
[
{
"productId": "100",
"productIdComponent": "200",
"quantity": "2.00",
"lastChanged": "25000"
}
]
```
### `GET /v1/customergroup`
| Param | Default |
|---|---|
| `lastChangedCustomerGroup` | `0` |
No `limit` — returns all groups newer than the cursor.
```json
[
{
"customerGroupId": "1",
"name": "Endkunde",
"standard": "1",
"discountPercent": "0.00",
"lastChanged": "100"
}
]
```
### `GET /v1/deletedentity`
| Param | Default |
|---|---|
| `lastChangedDeletedEntity` | `0` |
| `limit` | `600` |
```json
[
{ "entityId": "123", "entityType": "1", "lastChanged": "208980" }
]
```
| `entityType` | Entity to remove locally |
|---|---|
| `1` | Product |
| `2` | Category |
| `7` | Composite product |
---
## Images
### `GET /v1/pimage` — Product image
### `GET /v1/cimage` — Category image
| Param | Required | Notes |
|---|---|---|
| `path` | yes | Image hash from `imghash` / `imgsrc` |
| `size` | no | **C++ only.** Target max dimension in px (default `200`). Use `0` for the full unresized image. Node always serves a ≤200px resize and ignores `size`. |
**Success (200):** binary image body (`image/jpeg`, `image/png`, …), resized by the server.
**Errors:**
| Status | Body |
|---|---|
| 400 | `{ "Message": "Missing required query parameter 'path'." }` |
| 404 | `{ "Message": "No image was found for path '...'" }` |
---
## `POST /v1/order` — Submit orders
Creates Wawi sales orders (`Verkauf.tAuftrag`), maps POS ids, optionally delivers stock, and records payments.
### Request
```json
{
"orders": [ { /* Order */ } ]
}
```
### Order object
| Field | Type | Notes |
|---|---|---|
| `externalId` | string | POS order id. Used for idempotency via `Pos.tAuftragMapping`. |
| `externalOrderNumber` | string | Receipt / external number stored on the Auftrag |
| `creationDate` | string | `YYYY-MM-DD HH:mm:ss` (local server time). Fallback: now |
| `currencyIso` | string | Default `EUR` |
| `paymentMethodName` | string | Zahlungsart name (e.g. `BAR`); created if missing |
| `shippingName` | string | Versandart name; default lookup `Selbstabholer` |
| `customerNumber` | string | Existing customer number, or empty/`0` for walk-in |
| `billingAddress` | object | See address fields below |
| `shippingAddress` | object | See address fields below |
| `orderItems` | array | Line items |
| `payments` | array | Payments to apply |
| `settings.deliver` | string/bool | Default deliver on create (`1`/`true`). Set `0`/`false` to skip |
| `settings.importSetting` | string | `0` → read-only order, no Wawi invoice |
| `settings.invoiceSetting` | string | Bit flags; bit 0 enables Wawi invoice path |
### Address fields
Used on `billingAddress` / `shippingAddress`:
`firstName`, `lastName`, `company`, `street`, `zipCode`, `city`, `countryIso`, `phone`, `fax`, `email`, `mobile`, `salutation`, `title`, `state`, `extraAddressLine`, `addressAddition`, `discount`, `birthday`, `customerGroupId`, `debtorNumber`
Walk-in: omit `customerNumber` (or `"0"`) and use a minimal billing address (`lastName` often `Laufkunde`).
### Order item fields
| Field | Notes |
|---|---|
| `externalId` | POS line id (mapped in `Pos.tAuftragPositionMapping`) |
| `sku` | Article number; looked up in `tArtikel` |
| `name` | Display name |
| `quantity` | Quantity |
| `priceNet` / `priceGross` | Unit prices; net derived from gross + vat if omitted |
| `vat` | VAT percent (e.g. `"19.00"`) |
| `discountPercent` | Line discount |
| `unit` | Unit label |
| `note` | Position note |
| `type` | `2` = shipping line; otherwise article/free position |
| `isReturn` | `"1"` for returns |
If no shipping line (`type: "2"`) is present and the order has sale items, the server may inject a `Selbstabholer` shipping position automatically.
### Payment fields
| Field | Notes |
|---|---|
| `paymentId` | Only payments with id `≤ 0` / empty are inserted (new payments) |
| `paymentMethodName` | Overrides order-level method if set |
| `amount` | Payment amount |
### Response
Array of per-order results. HTTP **200** if all succeed; **500** if any failed or was skipped as already mapped.
```json
[
{ "status": "OK", "externalOrderId": "99", "message": "" },
{ "status": "ERROR", "externalOrderId": "100", "message": "..." }
]
```
| `status` | Meaning |
|---|---|
| `OK` | Order created |
| `ERROR` | Create failed, **or** order already mapped (idempotent skip) |
Invalid JSON body → `500` with `[]`.
### Minimal example
```json
{
"orders": [
{
"externalId": "99",
"externalOrderNumber": "T00100",
"creationDate": "2026-07-20 15:01:41",
"currencyIso": "EUR",
"paymentMethodName": "BAR",
"customerNumber": "0",
"shippingName": "",
"billingAddress": {
"lastName": "Laufkunde",
"countryIso": "DE"
},
"shippingAddress": {
"lastName": "Laufkunde",
"countryIso": "DE"
},
"orderItems": [
{
"externalId": "146",
"sku": "ART-001",
"name": "Example product",
"quantity": "1",
"priceNet": "10.00",
"priceGross": "11.90",
"vat": "19.00",
"type": "1",
"isReturn": "0",
"discountPercent": "0"
}
],
"payments": [
{ "paymentId": "", "paymentMethodName": "BAR", "amount": "11.90" }
],
"settings": {
"importSetting": "0",
"invoiceSetting": "0",
"deliver": "1"
}
}
]
}
```
---
## Errors (general)
| Status | Body |
|---|---|
| 404 | `{ "Message": "No HTTP resource was found that matches the request URI '...'." }` |
| 500 | `{ "Message": "<error message>" }` (unhandled handler errors) |
---
## Endpoint index
| Method | Path | Role |
|---|---|---|
| `GET` | `/v1/client` | Pairing / discovery |
| `GET` | `/v1/init` | Change counts for all entity cursors |
| `GET` | `/v1/category` | Category deltas |
| `GET` | `/v1/product` | Product deltas |
| `GET` | `/v1/productcomposite` | Composite BOM deltas |
| `GET` | `/v1/customergroup` | Customer group deltas |
| `GET` | `/v1/deletedentity` | Deletion deltas |
| `GET` | `/v1/pimage` | Product image by hash |
| `GET` | `/v1/cimage` | Category image by hash |
| `POST` | `/v1/order` | Create order(s) |
Handlers: Node [`src/endpoints/`](src/endpoints/), C++ [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/).
---
## Node vs C++
Both expose the same route table, pairing flow, cursor sync protocol, and order request/response contract. A client written against this document works with either binary.
### Same (client-visible)
| Area | Behavior |
|---|---|
| Routes | Identical methods and paths (`/v1/...` and `/api/v1/...`) |
| Pairing | Short `authCode` → discovery; 6-digit → pair / revoke / register |
| Init / list params | Same cursor names and default `limit` values |
| Category / composite / deleted | Same JSON field names and cursor semantics |
| Order | Same `{ "orders": [...] }` body, result array, `OK` / `ERROR`, HTTP 200 vs 500 |
| Auth | Neither validates `authToken` on sync/order after pairing |
### Differences that affect clients
| Area | Node | C++ |
|---|---|---|
| **Group prices** | Percustomer-group overrides from `tPreis` | Every group gets the base gross price |
| **Image `size`** | Ignored; always resize ≤200px | Honored (`size`, default `200`; `0` = full) |
| **`discountPercent`** | Fixed 2 decimals (`"0.00"`) | `std::to_string` of the double (e.g. `"0.000000"`) |
| **Init when DB is down** | Handler throws → HTTP 500 | Returns all counts as `"0"` (HTTP 200) |
| **Product `created_at`** | Formatted `YYYY-MM-DD HH:mm:ss` | Whatever ODBC returns for `dErstelldatum` |
### Safe client strategy
- Treat optional product fields (per-group price overrides) as best-effort when talking to C++.
- Prefer `price` for the default gross; do not assume `prices[]` differ by group unless you know you are on Node.
- Call images with `?path=<hash>` only; omit `size` unless you need C++-specific sizing.
- Parse `discountPercent` as a number, not a fixed-format string.
- On init, treat HTTP 500 and all-zero counts similarly as “nothing to sync / unavailable”.