531 lines
15 KiB
Markdown
531 lines
15 KiB
Markdown
# jtlsrv
|
|
|
|
Self-signed HTTPS debug server for **JTL-POS** pairing and sync, now split into a reusable library and a web admin UI.
|
|
|
|
## Project layout
|
|
|
|
```
|
|
.
|
|
├── lib/ # jtl-pos-server library
|
|
│ ├── src/
|
|
│ │ ├── index.js # public exports (createStore, createJtlPosServer, createAdminServer, actions, seed)
|
|
│ │ ├── store.js # in-memory state + reducer actions
|
|
│ │ ├── actions.js # action creators for CRUD + pairing
|
|
│ │ ├── seed.js # sample data
|
|
│ │ ├── jtl-server.js # JTL-POS HTTPS protocol handler
|
|
│ │ └── admin-server.js # Express admin API (/admin/api)
|
|
│ └── package.json
|
|
├── web/ # Vite + React + MUI admin UI
|
|
│ ├── src/
|
|
│ │ ├── main.jsx
|
|
│ │ ├── App.jsx
|
|
│ │ ├── api.js
|
|
│ │ ├── components/CrudTable.jsx
|
|
│ │ └── pages/*.jsx
|
|
│ ├── package.json
|
|
│ └── vite.config.js
|
|
├── certs/ # self-signed TLS certificate
|
|
├── server.js # thin wrapper: HTTPS POS server + admin API
|
|
├── test-client.js # probe real upstream or local server
|
|
└── generate-cert.js # create self-signed TLS cert
|
|
```
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
npm install # install root deps (express, concurrently, uuid, local lib)
|
|
cd web && npm install && cd ..
|
|
npm run cert # once, before first start
|
|
npm run dev # starts POS server + admin API + Vite dev server
|
|
```
|
|
|
|
- POS HTTPS endpoint: `https://localhost:8086`
|
|
- Admin JSON API: `http://localhost:8087/admin/api`
|
|
- Admin web UI: `http://localhost:5173`
|
|
|
|
Environment variables:
|
|
|
|
| Variable | Default | Purpose |
|
|
|---|---|---|
|
|
| `PORT` | `8086` | HTTPS POS server port |
|
|
| `ADMIN_PORT` | `8087` | Admin API / static UI port |
|
|
| `AUTH_TOKEN` | `9a2e3036ed9c47e389741d9dbb7590e9` | bearer token issued to paired devices |
|
|
| `PAIRING_CODE` | `307018` | pre-registered 6-digit pairing code |
|
|
|
|
## Production build of the web UI
|
|
|
|
```bash
|
|
npm run build # builds web/ into web/dist
|
|
npm start # starts POS + admin API and serves web/dist
|
|
```
|
|
|
|
After `npm run build`, `server.js` serves the built admin SPA from `web/dist` on `ADMIN_PORT`.
|
|
|
|
## Using the web admin
|
|
|
|
Open `http://localhost:5173` (dev) or `http://localhost:8087` (built).
|
|
|
|
| Page | What you can do |
|
|
|---|---|
|
|
| **Dashboard** | View entity counts and paired devices |
|
|
| **Pairing** | See/generate 6-digit pairing codes, list and revoke paired devices |
|
|
| **Products** | CRUD products; mark products as composite and assign component products |
|
|
| **Categories** | CRUD category tree via `pid` |
|
|
| **Customers** | CRUD customers |
|
|
| **Customer Groups** | CRUD customer groups |
|
|
| **Deleted** | View deletion events and manually record new deletions |
|
|
|
|
The admin API lives under `/admin/api` and mutates the same in-memory store as the POS sync endpoint.
|
|
|
|
## Programmatic use
|
|
|
|
```js
|
|
const { createStore, createJtlPosServer, createAdminServer, actions } = require('./lib/src');
|
|
|
|
const store = createStore();
|
|
store.dispatch(actions.setPairingCode('307018'));
|
|
|
|
const posHandler = createJtlPosServer(store, { authToken: '...' });
|
|
https.createServer({ key, cert }, posHandler).listen(8086);
|
|
|
|
const { server: adminServer } = createAdminServer(store, { adminPort: 8087 });
|
|
```
|
|
|
|
## Protocol overview
|
|
|
|
- **Transport:** HTTPS (TLS required)
|
|
- **Base path:** `/api/v1/`
|
|
- **Methods observed:** `GET` only (empty body)
|
|
- **Content type:** `application/json; charset=utf-8`
|
|
- **Numeric fields:** counts and IDs are returned as **strings**, not JSON numbers
|
|
|
|
### Request headers (JTL-POS client)
|
|
|
|
| Header | Example | Notes |
|
|
|---|---|---|
|
|
| `accept` | `application/json` | |
|
|
| `content-type` | `application/json` | sent even on GET |
|
|
| `cache-control` | `no-cache` | |
|
|
| `authorization` | `Bearer <authToken>` | required after pairing |
|
|
| `version` | `1.0.11.14` | POS app version |
|
|
| `system` | `JTL-POS` | |
|
|
| `charset` | `utf-8` | |
|
|
| `accept-encoding` | `gzip` | upstream may gzip responses |
|
|
| `user-agent` | `Dalvik/2.1.0 …` | Android |
|
|
|
|
### Error responses
|
|
|
|
Unknown paths return **404** with:
|
|
|
|
```json
|
|
{
|
|
"Message": "No HTTP resource was found that matches the request URI 'https://…'."
|
|
}
|
|
```
|
|
|
|
Pairing errors (upstream, German) observed on `/api/v1/client`:
|
|
|
|
| Situation | Message |
|
|
|---|---|
|
|
| Step 2 before step 1 | `Die erste Anfrage wurde noch nicht erfolgreich durchgeführt.` |
|
|
| Step 1 repeated | `Die erste Anfrage wurde bereits erfolgreich durchgeführt.` |
|
|
| Wrong code | `Der Authentifizierungscode ist falsch.` / `Keinen passenden Authentifizierungscode gefunden.` |
|
|
|
|
Auth codes are **single-use** and expire quickly.
|
|
|
|
---
|
|
|
|
## Flow 1: Pairing (`/api/v1/client`)
|
|
|
|
No `Authorization` header. Two-step GET handshake using a 6-digit code shown in JTL Wawi (or generated in the admin UI).
|
|
|
|
### Step 1 — first 4 digits
|
|
|
|
```
|
|
GET /api/v1/client?authCode=3070&name=001
|
|
```
|
|
|
|
Response (fields vary; `authCode` is `null` on success):
|
|
|
|
```json
|
|
{
|
|
"authCode": null,
|
|
"authToken": "9a2e3036ed9c47e389741d9dbb7590e9",
|
|
"certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879",
|
|
"certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3",
|
|
"mandantId": "1",
|
|
"mandantName": null,
|
|
"mandantDatabase": null,
|
|
"serverFingerprint": "39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE",
|
|
"name": null,
|
|
"serverTimestamp": "2026-06-16 16:37:53"
|
|
}
|
|
```
|
|
|
|
`serverFingerprint` is present in step 1; `mandantName` / `mandantDatabase` are null.
|
|
|
|
### Step 2 — full 6 digits
|
|
|
|
```
|
|
GET /api/v1/client?authCode=307018&name=001
|
|
```
|
|
|
|
```json
|
|
{
|
|
"authCode": "307018",
|
|
"authToken": "9a2e3036ed9c47e389741d9dbb7590e9",
|
|
"certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879",
|
|
"certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3",
|
|
"mandantId": "1",
|
|
"mandantName": "eB-Standard",
|
|
"mandantDatabase": "eazybusiness",
|
|
"serverFingerprint": null,
|
|
"name": null,
|
|
"serverTimestamp": "2026-06-16 16:37:53"
|
|
}
|
|
```
|
|
|
|
`authToken` is the bearer token used for all subsequent sync calls. `serverTimestamp` format: `YYYY-MM-DD HH:MM:SS`.
|
|
|
|
Probe pairing:
|
|
|
|
```bash
|
|
node test-client.js 307018 # runs step 1 + step 2
|
|
node test-client.js 3070 # step 1 only
|
|
```
|
|
|
|
---
|
|
|
|
## Flow 2: Sync
|
|
|
|
After pairing, the POS polls with `Authorization: Bearer <authToken>`.
|
|
|
|
### `/api/v1/init` — sync manifest
|
|
|
|
Returns how many entities still need syncing, based on the client's stored cursors.
|
|
|
|
```
|
|
GET /api/v1/init?mandantId=1
|
|
&lastChangedCategory=0
|
|
&lastChangedCustomer=0
|
|
&lastChangedCustomerGroup=0
|
|
&lastChangedProduct=0
|
|
&lastChangedConfigurationGroup=0
|
|
&lastChangedConfigurationItem=0
|
|
&lastChangedCompositeProduct=0
|
|
&lastChangedDeletedEntity=0
|
|
```
|
|
|
|
Response (fresh mandant, all cursors at `0`):
|
|
|
|
```json
|
|
{
|
|
"version": "1.10.12.0",
|
|
"product_count": "3",
|
|
"category_count": "2",
|
|
"customer_count": "3",
|
|
"customerGroup_count": "1",
|
|
"compositeProduct_count": "1",
|
|
"configurationGroup_count": "0",
|
|
"configurationItem_count": "0",
|
|
"deletedEntity_count": "1",
|
|
"max_orderId_count": "0"
|
|
}
|
|
```
|
|
|
|
Each `*_count` is the number of records with `lastChanged` **greater than** the matching `lastChanged*` query parameter. Example: if the client has synced customer groups up to cursor `7323`, pass `lastChangedCustomerGroup=7323` and `customerGroup_count` becomes `"0"`.
|
|
|
|
Typical poll sequence after initial sync:
|
|
|
|
1. `init` with all cursors at latest values → counts are `"0"` when fully synced
|
|
2. If a count is `> 0`, POS fetches the corresponding entity endpoint
|
|
3. POS updates its local cursor to the highest `lastChanged` received
|
|
4. Repeat
|
|
|
|
Probe:
|
|
|
|
```bash
|
|
node test-client.js init <authToken>
|
|
```
|
|
|
|
---
|
|
|
|
### Incremental sync pattern
|
|
|
|
Entity list endpoints use the same cursor model:
|
|
|
|
- Query param: `lastChanged<EntityType>=<cursor>` (client's last known value)
|
|
- Response: JSON **array** of records where `record.lastChanged > cursor`
|
|
- Empty array `[]` means the client is up to date
|
|
- After sync, the client stores `max(lastChanged)` and sends that on the next poll
|
|
|
|
---
|
|
|
|
### `/api/v1/customergroup`
|
|
|
|
```
|
|
GET /api/v1/customergroup?mandantId=1&lastChangedCustomerGroup=0
|
|
```
|
|
|
|
```json
|
|
[
|
|
{
|
|
"customerGroupId": "1",
|
|
"name": "Endkunden",
|
|
"standard": "1",
|
|
"discountPercent": "0.00",
|
|
"lastChanged": "7323"
|
|
}
|
|
]
|
|
```
|
|
|
|
| `lastChangedCustomerGroup` | Result |
|
|
|---|---|
|
|
| `0` | all groups changed since beginning |
|
|
| `7323` | `[]` (client has this revision) |
|
|
| `7324` | `[]` |
|
|
|
|
Probe:
|
|
|
|
```bash
|
|
node test-client.js customergroup <authToken>
|
|
node test-client.js customergroup <authToken> 7323
|
|
```
|
|
|
|
---
|
|
|
|
### `/api/v1/deletedentity`
|
|
|
|
```
|
|
GET /api/v1/deletedentity?mandantId=1&limit=200&lastChangedDeletedEntity=0
|
|
```
|
|
|
|
```json
|
|
[
|
|
{
|
|
"entityId": "2",
|
|
"entityType": "6",
|
|
"lastChanged": "8281"
|
|
}
|
|
]
|
|
```
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `entityId` | ID of the deleted record |
|
|
| `entityType` | numeric type code (`6` observed for a customer group) |
|
|
| `lastChanged` | revision cursor for this deletion event |
|
|
|
|
| `lastChangedDeletedEntity` | Result |
|
|
|---|---|
|
|
| `0` | pending deletions |
|
|
| `8281` | `[]` (client has processed this deletion) |
|
|
|
|
`limit` caps how many records are returned per request (POS uses `200`).
|
|
|
|
Probe:
|
|
|
|
```bash
|
|
node test-client.js deletedentity <authToken>
|
|
node test-client.js deletedentity <authToken> 8281
|
|
```
|
|
|
|
---
|
|
|
|
### `/api/v1/category`
|
|
|
|
```
|
|
GET /api/v1/category?mandantId=1&limit=20&lastChangedCategory=0
|
|
```
|
|
|
|
```json
|
|
[
|
|
{
|
|
"_id": "1",
|
|
"imghash": null,
|
|
"imgsrc": null,
|
|
"name": "Haupt",
|
|
"pid": "0",
|
|
"discounts": [],
|
|
"sort": "0",
|
|
"updated_at": "2026-06-16 22:42:49",
|
|
"created_at": "2026-06-16 22:42:49",
|
|
"lastChanged": "10252"
|
|
}
|
|
]
|
|
```
|
|
|
|
`pid` is the parent category ID (`"0"` = root). The server also serves a sample child category `Getränke` (`_id: "2"`, `pid: "1"`).
|
|
|
|
Probe: `node test-client.js category <authToken>`
|
|
|
|
---
|
|
|
|
### `/api/v1/product`
|
|
|
|
```
|
|
GET /api/v1/product?mandantId=1&limit=20&lastChangedProduct=0
|
|
```
|
|
|
|
Large product objects; key fields:
|
|
|
|
| Field | Notes |
|
|
|---|---|
|
|
| `_id` | product ID |
|
|
| `name`, `sku`, `price`, `tax_rate` | basics |
|
|
| `categories_id` / `categories` | category linkage |
|
|
| `isCompositeProduct` | `"1"` if this product is a bundle/set |
|
|
| `prices` | per customer group / customer overrides |
|
|
| `lastChanged` | sync cursor |
|
|
|
|
Upstream example products: `a1` (normal), `a2` (`isCompositeProduct: "1"`). The debug server also adds `Cola 0,5l`.
|
|
|
|
Composite **definitions** (which components make up a bundle) are **not** embedded in the product object — see `/api/v1/productcomposite`.
|
|
|
|
Probe: `node test-client.js product <authToken>`
|
|
|
|
---
|
|
|
|
### `/api/v1/customer`
|
|
|
|
```
|
|
GET /api/v1/customer?mandantId=1&limit=20&lastChangedCustomer=0
|
|
```
|
|
|
|
```json
|
|
[
|
|
{
|
|
"id": "1",
|
|
"customerNumber": "0",
|
|
"firstname": "",
|
|
"lastname": "kjhkjh",
|
|
"company": "kjhkjh",
|
|
"city": "kjhkjhkjh",
|
|
"country": "Deutschland",
|
|
"customerGroupId": "1",
|
|
"discount": "0.00",
|
|
"lastChanged": "13246",
|
|
"debtorNumber": "0"
|
|
}
|
|
]
|
|
```
|
|
|
|
The debug server also includes sample customers `Muster GmbH` and `Max Mustermann`.
|
|
|
|
Probe: `node test-client.js customer <authToken>`
|
|
|
|
---
|
|
|
|
### `/api/v1/productcomposite` — composite product components
|
|
|
|
There is **no** `/api/v1/compositeproduct` endpoint (returns 404). Composite bundles use this path instead:
|
|
|
|
```
|
|
GET /api/v1/productcomposite?mandantId=1&limit=20&lastChangedCompositeProduct=0
|
|
```
|
|
|
|
```json
|
|
[
|
|
{
|
|
"productId": "2",
|
|
"productIdComponent": "1",
|
|
"quantity": "1.00",
|
|
"lastChanged": "13227"
|
|
}
|
|
]
|
|
```
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `productId` | the composite/bundle product (`a2`, `isCompositeProduct: "1"`) |
|
|
| `productIdComponent` | component product included in the bundle |
|
|
| `quantity` | how many units of the component |
|
|
|
|
`init.compositeProduct_count` tracks pending rows here, separate from `product_count`.
|
|
|
|
Probe: `node test-client.js productcomposite <authToken>`
|
|
|
|
---
|
|
|
|
## Observed sync order
|
|
|
|
After pairing, the POS roughly follows:
|
|
|
|
```
|
|
init → customergroup → init → category → init → product → init →
|
|
productcomposite → init → customer → init → deletedentity → init → …
|
|
```
|
|
|
|
`init` is called before and after each entity fetch. Counts in `init` drive which endpoint the POS calls next.
|
|
|
|
---
|
|
|
|
## Implemented in server
|
|
|
|
| Endpoint | Sample data |
|
|
|---|---|
|
|
| `/api/v1/client` | pairing flow |
|
|
| `/api/v1/init` | dynamic counts from cursors |
|
|
| `/api/v1/customergroup` | 1 group (Endkunden) |
|
|
| `/api/v1/category` | 2 categories (Haupt + Getränke) |
|
|
| `/api/v1/product` | 3 products (a1, a2 composite, Cola) |
|
|
| `/api/v1/productcomposite` | 1 bundle link (a2 → a1) |
|
|
| `/api/v1/customer` | 3 customers |
|
|
| `/api/v1/deletedentity` | 1 deletion event |
|
|
|
|
---
|
|
|
|
## Admin API endpoints
|
|
|
|
All prefixed with `/admin/api`.
|
|
|
|
| Method | Path | Description |
|
|
|---|---|---|
|
|
| GET | `/init` | counts + full state snapshot |
|
|
| GET | `/state` | current in-memory state |
|
|
| GET | `/categories` | list categories |
|
|
| POST | `/categories` | create category |
|
|
| PATCH | `/categories/:id` | update category |
|
|
| DELETE | `/categories/:id` | delete category |
|
|
| GET | `/products` | list products |
|
|
| POST | `/products` | create product |
|
|
| PATCH | `/products/:id` | update product |
|
|
| DELETE | `/products/:id` | delete product |
|
|
| GET | `/customers` | list customers |
|
|
| POST | `/customers` | create customer |
|
|
| PATCH | `/customers/:id` | update customer |
|
|
| DELETE | `/customers/:id` | delete customer |
|
|
| GET | `/customer-groups` | list customer groups |
|
|
| POST | `/customer-groups` | create group |
|
|
| PATCH | `/customer-groups/:id` | update group |
|
|
| DELETE | `/customer-groups/:id` | delete group |
|
|
| GET | `/product-composites` | list composite links |
|
|
| POST | `/product-composites` | add or update composite link |
|
|
| DELETE | `/product-composites/:parent/:component` | remove link |
|
|
| GET | `/pairing` | pending codes + paired devices |
|
|
| POST | `/pairing` | generate a new 6-digit code (`{ name }`) |
|
|
| POST | `/pairing/revoke` | revoke a pending code |
|
|
| DELETE | `/devices/:token` | revoke a paired device |
|
|
| GET | `/deleted` | list deletion events |
|
|
| POST | `/deleted` | record a deletion (`{ entityId, entityType }`) |
|
|
|
|
---
|
|
|
|
## Endpoints not yet implemented
|
|
|
|
| `init` count field | Endpoint | Notes |
|
|
|---|---|---|
|
|
| `configurationGroup_count` | `/api/v1/configurationgroup` | returns `[]` upstream (no data yet) |
|
|
| `configurationItem_count` | `/api/v1/configurationitem` | returns `[]` upstream (no data yet) |
|
|
|
|
---
|
|
|
|
## Development workflow
|
|
|
|
1. Run `npm run dev` to start both servers and the web UI.
|
|
2. Point POS at `https://<host>:8086` and pair with code `307018` (or generate a new one in the web UI).
|
|
3. Manage sample data through the web admin; counts in `/api/v1/init` update automatically.
|
|
4. If the POS requests an unknown path, replay it against the upstream server with `test-client.js`, then extend `lib/src/jtl-server.js` or the relevant action in `lib/src/store.js`.
|
|
5. For list endpoints, ensure records are filtered by `lastChanged > cursor` and honors `limit`.
|
|
6. Keep IDs and counts as strings to match upstream.
|