diff --git a/docs/api/README.md b/docs/api/README.md index b684550..2434c54 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -102,6 +102,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/events.md`](routes/events.md) | `GET /events` | | [`routes/health.md`](routes/health.md) | `GET /health` | | [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | +| [`routes/ledger.get.md`](routes/ledger.get.md) | `GET /ledger` | +| [`routes/ledger.post.md`](routes/ledger.post.md) | `POST /ledger` | +| [`routes/ledger/manifestarticle/:manifestarticleId.md`](routes/ledger/manifestarticle/:manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` | +| [`routes/ledger/sku/:sku.md`](routes/ledger/sku/:sku.md) | `GET /ledger/sku/{sku}` | | [`routes/number/list.md`](routes/number/list.md) | `GET /number` | | [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` | | [`routes/order/list.md`](routes/order/list.md) | `GET /order` | @@ -150,5 +154,5 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A - that should loosly bound to services in /src/services - for every service we have a CLI tester in /scripts/ - all DB SQL code shall be in /src/db -- with mssql staements in /src/db/mssql/ +- with SQL staements in /src/db/ - API docs: `@openapi` JSDoc on handlers → `npm run docs:api` → `docs/api/` diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 362f1e0..d658d68 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -79,6 +79,10 @@ { "name": "Posterminal", "description": "Company-scoped POS terminals" + }, + { + "name": "Ledger", + "description": "Append-only ledger transactions" } ], "paths": { @@ -2794,6 +2798,347 @@ } } }, + "/ledger": { + "post": { + "tags": [ + "Ledger" + ], + "summary": "Add ledger entry", + "description": "Appends a new entry to the append-only ledger table.\n`hash_version` is fixed to 1. `prev_hash` is cryptographically chained:\nfor the genesis row it defaults to `TODO_TSE_BASED_INITHASH`, and for subsequent rows\nit is the SHA-512 hex hash of the canonical JSON representation of the previous highest-id entry.\nRequires authentication and membership in the company owning the referenced manifest article.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LedgerEntryInput" + }, + "example": { + "manifestarticle_id": 1, + "transaction_type": "PAYMENT", + "amount": 150, + "transaction_text": "Payment received for invoice" + } + } + } + }, + "responses": { + "201": { + "description": "Ledger entry added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ledger": { + "$ref": "#/components/schemas/LedgerEntry" + } + } + } + } + } + }, + "400": { + "description": "Invalid ledger entry or no company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Manifest article or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "get": { + "tags": [ + "Ledger" + ], + "summary": "Query ledger by manifest article ID or SKU", + "description": "Alternative query endpoint supporting `?manifestarticle_id=...` or `?sku=...`,\nwith optional `?transaction_type=...`.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "manifestarticle_id", + "schema": { + "type": "integer" + }, + "description": "Query entries for this manifest article line ID" + }, + { + "in": "query", + "name": "sku", + "schema": { + "type": "string" + }, + "description": "Query entries for this article SKU" + }, + { + "in": "query", + "name": "transaction_type", + "schema": { + "type": "string" + }, + "description": "Optional transaction type filter" + } + ], + "responses": { + "200": { + "description": "List of ledger entries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ledger": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LedgerEntry" + } + } + } + } + } + } + }, + "400": { + "description": "Missing required query parameter or no company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/ledger/manifestarticle/{manifestarticleId}": { + "get": { + "tags": [ + "Ledger" + ], + "summary": "Query ledger by manifest article ID", + "description": "Lists ledger entries for a specific manifest article line, optionally filtered by transaction type.", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "manifestarticleId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Manifest article line ID" + }, + { + "in": "query", + "name": "transaction_type", + "schema": { + "type": "string" + }, + "description": "Optional transaction type filter" + } + ], + "responses": { + "200": { + "description": "List of ledger entries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ledger": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LedgerEntry" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid manifest article id or no company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Manifest article or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/ledger/sku/{sku}": { + "get": { + "tags": [ + "Ledger" + ], + "summary": "Query ledger by article SKU", + "description": "Lists ledger entries for an article SKU (joined from manifestarticle), optionally filtered by transaction type.", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "sku", + "required": true, + "schema": { + "type": "string" + }, + "description": "Article SKU" + }, + { + "in": "query", + "name": "transaction_type", + "schema": { + "type": "string" + }, + "description": "Optional transaction type filter" + } + ], + "responses": { + "200": { + "description": "List of ledger entries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ledger": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LedgerEntry" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid SKU or no company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/health": { "get": { "tags": [ @@ -2848,7 +3193,7 @@ "Events" ], "summary": "Subscribe to update signals (SSE)", - "description": "Opens a Server-Sent Events stream for the authenticated user's\n**selected company**. Clients receive named signals when another\nrequest successfully mutates shared data for that company\n(including the same browser's other tabs).\n\nUse `EventSource` with credentials so the session cookie is sent:\n\n```js\nconst es = new EventSource('https://…/api/events', { withCredentials: true });\nes.addEventListener('category', () => { refetchCategoryTree(); });\nes.addEventListener('articles', () => { refetchArticleIndex(); });\nes.addEventListener('article-42000045', () => { refetchArticle('42000045'); });\n```\n\nKeep-alive comments (`: keepalive`) are sent roughly every 25s.\nReconnect after changing the selected company (the stream is bound\nto the company at connect time).\n\n## Signals\n\n| Signal | Emitted after successful |\n|--------|--------------------------|\n| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |\n| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |\n| `article-` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |\n\nSignals are only sent when the mutation actually succeeds (2xx).\nFailed or no-op validation errors do not emit. The `article-`\nevent name uses the concrete SKU (e.g. `article-42000045`).\n", + "description": "Opens a Server-Sent Events stream for the authenticated user's\n**selected company**. Clients receive named signals when another\nrequest successfully mutates shared data for that company\n(including the same browser's other tabs).\n\nUse `EventSource` with credentials so the session cookie is sent:\n\n```js\nconst es = new EventSource('https://…/api/events', { withCredentials: true });\nes.addEventListener('category', () => { refetchCategoryTree(); });\nes.addEventListener('articles', () => { refetchArticleIndex(); });\nes.addEventListener('article-42000045', () => { refetchArticle('42000045'); });\n```\n\nKeep-alive comments (`: heartbeat`) are sent roughly every 20s.\nReconnect after changing the selected company (the stream is bound\nto the company at connect time).\n\n## Signals\n\n| Signal | Emitted after successful |\n|--------|--------------------------|\n| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |\n| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |\n| `article-` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |\n\nSignals are only sent when the mutation actually succeeds (2xx).\nFailed or no-op validation errors do not emit. The `article-`\nevent name uses the concrete SKU (e.g. `article-42000045`).\n", "security": [ { "cookieAuth": [] @@ -10613,6 +10958,83 @@ "description": "Base64 thumbhash of thumb preview" } } + }, + "LedgerEntry": { + "type": "object", + "required": [ + "id", + "manifestarticle_id", + "transaction_type", + "amount", + "transaction_text", + "hash_version" + ], + "properties": { + "id": { + "type": "integer" + }, + "manifestarticle_id": { + "type": "integer", + "description": "References manifestarticle.id" + }, + "transaction_type": { + "type": "string", + "description": "Transaction identifier / type" + }, + "amount": { + "type": "number", + "description": "Movement amount" + }, + "transaction_text": { + "type": "string", + "description": "Transaction memo / details" + }, + "hash_version": { + "type": "integer", + "description": "Algorithm version (1)" + }, + "prev_hash": { + "type": "string", + "description": "Previous entry SHA-512 hex or initial seed hash" + }, + "sku": { + "type": "string", + "nullable": true, + "description": "SKU from referenced manifestarticle (present in query views)" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the row was created" + } + } + }, + "LedgerEntryInput": { + "type": "object", + "required": [ + "manifestarticle_id", + "transaction_type", + "amount", + "transaction_text" + ], + "properties": { + "manifestarticle_id": { + "type": "integer", + "description": "References manifestarticle.id" + }, + "transaction_type": { + "type": "string", + "description": "Transaction identifier / type (max 100 chars)" + }, + "amount": { + "type": "number", + "description": "Movement amount" + }, + "transaction_text": { + "type": "string", + "description": "Transaction memo / details" + } + } } } } diff --git a/docs/api/routes/events.md b/docs/api/routes/events.md index e007ff8..3b0cc9a 100644 --- a/docs/api/routes/events.md +++ b/docs/api/routes/events.md @@ -18,7 +18,7 @@ es.addEventListener('articles', () => { refetchArticleIndex(); }); es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); ``` -Keep-alive comments (`: keepalive`) are sent roughly every 25s. +Keep-alive comments (`: heartbeat`) are sent roughly every 20s. Reconnect after changing the selected company (the stream is bound to the company at connect time). diff --git a/docs/api/routes/ledger.get.md b/docs/api/routes/ledger.get.md new file mode 100644 index 0000000..1a9baaa --- /dev/null +++ b/docs/api/routes/ledger.get.md @@ -0,0 +1,49 @@ +# `GET /ledger` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Query ledger by manifest article ID or SKU + +Alternative query endpoint supporting `?manifestarticle_id=...` or `?sku=...`, +with optional `?transaction_type=...`. + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `manifestarticle_id` | no | integer | Query entries for this manifest article line ID | +| `sku` | no | string | Query entries for this article SKU | +| `transaction_type` | no | string | Optional transaction type filter | + +## Response `200` + +List of ledger entries + +```json +{ + "ledger": [ + { + "id": 1, + "manifestarticle_id": 1, + "transaction_type": "…", + "amount": 1, + "transaction_text": "…", + "hash_version": 1, + "prev_hash": "…", + "sku": "…", + "created_at": "2026-08-10T00:00:00.000Z" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Missing required query parameter or no company selected — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/ledger.post.md b/docs/api/routes/ledger.post.md new file mode 100644 index 0000000..97a4088 --- /dev/null +++ b/docs/api/routes/ledger.post.md @@ -0,0 +1,56 @@ +# `POST /ledger` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Add ledger entry + +Appends a new entry to the append-only ledger table. +`hash_version` is fixed to 1. `prev_hash` is cryptographically chained: +for the genesis row it defaults to `TODO_TSE_BASED_INITHASH`, and for subsequent rows +it is the SHA-512 hex hash of the canonical JSON representation of the previous highest-id entry. +Requires authentication and membership in the company owning the referenced manifest article. + + +## Body (JSON) + +```json +{ + "manifestarticle_id": 1, + "transaction_type": "…", + "amount": 1, + "transaction_text": "…" +} +``` + +## Response `201` + +Ledger entry added successfully + +```json +{ + "ledger": { + "id": 1, + "manifestarticle_id": 1, + "transaction_type": "…", + "amount": 1, + "transaction_text": "…", + "hash_version": 1, + "prev_hash": "…", + "sku": "…", + "created_at": "2026-08-10T00:00:00.000Z" + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid ledger entry or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Manifest article or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/ledger/manifestarticle/:manifestarticleId.md b/docs/api/routes/ledger/manifestarticle/:manifestarticleId.md new file mode 100644 index 0000000..0494727 --- /dev/null +++ b/docs/api/routes/ledger/manifestarticle/:manifestarticleId.md @@ -0,0 +1,54 @@ +# `GET /ledger/manifestarticle/{manifestarticleId}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Query ledger by manifest article ID + +Lists ledger entries for a specific manifest article line, optionally filtered by transaction type. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `manifestarticleId` | yes | integer | Manifest article line ID | + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `transaction_type` | no | string | Optional transaction type filter | + +## Response `200` + +List of ledger entries + +```json +{ + "ledger": [ + { + "id": 1, + "manifestarticle_id": 1, + "transaction_type": "…", + "amount": 1, + "transaction_text": "…", + "hash_version": 1, + "prev_hash": "…", + "sku": "…", + "created_at": "2026-08-10T00:00:00.000Z" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid manifest article id or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Manifest article or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/ledger/sku/:sku.md b/docs/api/routes/ledger/sku/:sku.md new file mode 100644 index 0000000..aa72259 --- /dev/null +++ b/docs/api/routes/ledger/sku/:sku.md @@ -0,0 +1,54 @@ +# `GET /ledger/sku/{sku}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Query ledger by article SKU + +Lists ledger entries for an article SKU (joined from manifestarticle), optionally filtered by transaction type. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `sku` | yes | string | Article SKU | + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `transaction_type` | no | string | Optional transaction type filter | + +## Response `200` + +List of ledger entries + +```json +{ + "ledger": [ + { + "id": 1, + "manifestarticle_id": 1, + "transaction_type": "…", + "amount": 1, + "transaction_text": "…", + "hash_version": 1, + "prev_hash": "…", + "sku": "…", + "created_at": "2026-08-10T00:00:00.000Z" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid SKU or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 190cc59..c94f86a 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -45,6 +45,7 @@ const options = { { name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' }, { name: 'Image', description: 'Content-addressed AVIF by fullHash' }, { name: 'Posterminal', description: 'Company-scoped POS terminals' }, + { name: 'Ledger', description: 'Append-only ledger transactions' }, ], }, apis: [ diff --git a/src/api/openapi.js b/src/api/openapi.js index 520c511..5d3b230 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -663,6 +663,54 @@ * thumbHash: * type: string * description: Base64 thumbhash of thumb preview + * LedgerEntry: + * type: object + * required: [id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version] + * properties: + * id: + * type: integer + * manifestarticle_id: + * type: integer + * description: References manifestarticle.id + * transaction_type: + * type: string + * description: Transaction identifier / type + * amount: + * type: number + * description: Movement amount + * transaction_text: + * type: string + * description: Transaction memo / details + * hash_version: + * type: integer + * description: Algorithm version (1) + * prev_hash: + * type: string + * description: Previous entry SHA-512 hex or initial seed hash + * sku: + * type: string + * nullable: true + * description: SKU from referenced manifestarticle (present in query views) + * created_at: + * type: string + * format: date-time + * description: Timestamp when the row was created + * LedgerEntryInput: + * type: object + * required: [manifestarticle_id, transaction_type, amount, transaction_text] + * properties: + * manifestarticle_id: + * type: integer + * description: References manifestarticle.id + * transaction_type: + * type: string + * description: Transaction identifier / type (max 100 chars) + * amount: + * type: number + * description: Movement amount + * transaction_text: + * type: string + * description: Transaction memo / details */ export {}; diff --git a/src/api/routes/deliveryParse.js b/src/api/routes/deliveryParse.js index b136ceb..28b91f3 100644 --- a/src/api/routes/deliveryParse.js +++ b/src/api/routes/deliveryParse.js @@ -287,7 +287,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) { }); // ── LLMOrbiter setup ───────────────────────────────────────────── - const model = '~openai/gpt-luna-latest:floor'; + const model = 'stealth/space-bunny-alpha';//'~openai/gpt-luna-latest:floor'; const provider = new OpenRouterProvider({ apiKey }); const orbiter = new LLMOrbiter(provider, { onStatus: (status) => { diff --git a/src/api/routes/ledger.js b/src/api/routes/ledger.js new file mode 100644 index 0000000..779f853 --- /dev/null +++ b/src/api/routes/ledger.js @@ -0,0 +1,342 @@ +import * as db from '../../db/ledger.js'; + +function requireSelectedCompany(req) { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function ledgerErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found') return 404; + if (error.message === 'Manifest article not found') return 404; + if (error.message === 'Invalid ledger entry') return 400; + if (error.message === 'Invalid manifest article id') return 400; + if (error.message === 'Invalid SKU') return 400; + return 500; +} + +/** + * Registers ledger routes for the authenticated user's selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /ledger: + * post: + * tags: [Ledger] + * summary: Add ledger entry + * description: | + * Appends a new entry to the append-only ledger table. + * `hash_version` is fixed to 1. `prev_hash` is cryptographically chained: + * for the genesis row it defaults to `TODO_TSE_BASED_INITHASH`, and for subsequent rows + * it is the SHA-512 hex hash of the canonical JSON representation of the previous highest-id entry. + * Requires authentication and membership in the company owning the referenced manifest article. + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LedgerEntryInput' + * example: + * manifestarticle_id: 1 + * transaction_type: 'PAYMENT' + * amount: 150.00 + * transaction_text: 'Payment received for invoice' + * responses: + * 201: + * description: Ledger entry added successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * ledger: + * $ref: '#/components/schemas/LedgerEntry' + * 400: + * description: Invalid ledger entry or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Manifest article or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.post(`${basePath}/ledger`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const ledger = await db.add(req.user.id, req.body ?? {}); + res.status(201).json({ ledger }); + } catch (error) { + const status = ledgerErrorStatus(error); + if (status === 500) console.error('Add ledger entry failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /ledger/manifestarticle/{manifestarticleId}: + * get: + * tags: [Ledger] + * summary: Query ledger by manifest article ID + * description: Lists ledger entries for a specific manifest article line, optionally filtered by transaction type. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: manifestarticleId + * required: true + * schema: + * type: integer + * description: Manifest article line ID + * - in: query + * name: transaction_type + * schema: + * type: string + * description: Optional transaction type filter + * responses: + * 200: + * description: List of ledger entries + * content: + * application/json: + * schema: + * type: object + * properties: + * ledger: + * type: array + * items: + * $ref: '#/components/schemas/LedgerEntry' + * 400: + * description: Invalid manifest article id or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Manifest article or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/ledger/manifestarticle/:manifestarticleId`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const manifestarticleId = Number(req.params.manifestarticleId); + const transactionType = req.query.transaction_type ?? req.query.transactionType; + + try { + const ledger = await db.listByManifestarticleId( + req.user.id, + manifestarticleId, + transactionType, + ); + res.status(200).json({ ledger }); + } catch (error) { + const status = ledgerErrorStatus(error); + if (status === 500) console.error('Query ledger by manifestarticle failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /ledger/sku/{sku}: + * get: + * tags: [Ledger] + * summary: Query ledger by article SKU + * description: Lists ledger entries for an article SKU (joined from manifestarticle), optionally filtered by transaction type. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * - in: query + * name: transaction_type + * schema: + * type: string + * description: Optional transaction type filter + * responses: + * 200: + * description: List of ledger entries + * content: + * application/json: + * schema: + * type: object + * properties: + * ledger: + * type: array + * items: + * $ref: '#/components/schemas/LedgerEntry' + * 400: + * description: Invalid SKU or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/ledger/sku/:sku`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const sku = req.params.sku; + const transactionType = req.query.transaction_type ?? req.query.transactionType; + + try { + const ledger = await db.listBySku(req.user.id, sku, transactionType); + res.status(200).json({ ledger }); + } catch (error) { + const status = ledgerErrorStatus(error); + if (status === 500) console.error('Query ledger by sku failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /ledger: + * get: + * tags: [Ledger] + * summary: Query ledger by manifest article ID or SKU + * description: | + * Alternative query endpoint supporting `?manifestarticle_id=...` or `?sku=...`, + * with optional `?transaction_type=...`. + * security: + * - cookieAuth: [] + * parameters: + * - in: query + * name: manifestarticle_id + * schema: + * type: integer + * description: Query entries for this manifest article line ID + * - in: query + * name: sku + * schema: + * type: string + * description: Query entries for this article SKU + * - in: query + * name: transaction_type + * schema: + * type: string + * description: Optional transaction type filter + * responses: + * 200: + * description: List of ledger entries + * content: + * application/json: + * schema: + * type: object + * properties: + * ledger: + * type: array + * items: + * $ref: '#/components/schemas/LedgerEntry' + * 400: + * description: Missing required query parameter or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/ledger`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const rawManifestarticleId = req.query.manifestarticle_id ?? req.query.manifestarticleId; + const sku = req.query.sku; + const transactionType = req.query.transaction_type ?? req.query.transactionType; + + if (rawManifestarticleId == null && (sku == null || !String(sku).trim())) { + return res.status(400).json({ + error: 'Either manifestarticle_id or sku query parameter is required', + }); + } + + try { + let ledger; + if (rawManifestarticleId != null) { + ledger = await db.listByManifestarticleId( + req.user.id, + Number(rawManifestarticleId), + transactionType, + ); + } else { + ledger = await db.listBySku(req.user.id, String(sku).trim(), transactionType); + } + res.status(200).json({ ledger }); + } catch (error) { + const status = ledgerErrorStatus(error); + if (status === 500) console.error('Query ledger failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} diff --git a/src/db/ledger.js b/src/db/ledger.js new file mode 100644 index 0000000..80914c4 --- /dev/null +++ b/src/db/ledger.js @@ -0,0 +1,43 @@ +import { getStore } from './store.js'; + +/** + * Appends a new entry to the append-only ledger table. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} input - Ledger payload. + * @param {number} input.manifestarticle_id - ID of the referenced manifest article. + * @param {string} input.transaction_type - Transaction type. + * @param {number} input.amount - Movement amount. + * @param {string} input.transaction_text - Transaction text / memo. + * @returns {Promise} The inserted ledger row. + */ +export async function add(googleAuthId, input) { + const store = await getStore(); + return store.ledger.add(googleAuthId, input); +} + +/** + * Lists ledger entries for a specific manifest article line, optionally filtered by transaction type. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} manifestarticleId - Manifest line ID. + * @param {string} [transactionType] - Optional transaction type filter. + * @returns {Promise>} List of ledger entries. + */ +export async function listByManifestarticleId(googleAuthId, manifestarticleId, transactionType) { + const store = await getStore(); + return store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId, transactionType); +} + +/** + * Lists ledger entries for a specific article SKU, optionally filtered by transaction type. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {string} sku - Article SKU. + * @param {string} [transactionType] - Optional transaction type filter. + * @returns {Promise>} List of ledger entries. + */ +export async function listBySku(googleAuthId, sku, transactionType) { + const store = await getStore(); + return store.ledger.listBySku(googleAuthId, sku, transactionType); +} diff --git a/src/db/store.js b/src/db/store.js index b4e93a4..af11762 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -32,6 +32,7 @@ const STORE_NAMESPACES = [ 'companysetting', 'articlesupplier', 'manifestattachment', + 'ledger', ]; /**