diff --git a/API.md b/API.md new file mode 100644 index 0000000..396ad55 --- /dev/null +++ b/API.md @@ -0,0 +1,438 @@ +# 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. + +Base URL: `https://:` (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. + +--- + +## `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 (1–4 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 1–4). 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 | + +Many other product fields are filled with static defaults (`sort`, `use_stock`, `unit`, etc.) for JTL-POS compatibility. + +### `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 | +|---|---| +| `path` | yes — image hash from `imghash` / `imgsrc` | + +**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": "" }` (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 live in [`src/endpoints/`](src/endpoints/).