Files
jtlsrv/README.md
2026-06-21 09:09:06 +02:00

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.