This commit is contained in:
seb
2026-09-24 23:26:50 +02:00
parent 68aadd483b
commit 37828fdef9
13 changed files with 1078 additions and 4 deletions

View File

@@ -102,6 +102,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/events.md`](routes/events.md) | `GET /events` | | [`routes/events.md`](routes/events.md) | `GET /events` |
| [`routes/health.md`](routes/health.md) | `GET /health` | | [`routes/health.md`](routes/health.md) | `GET /health` |
| [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | | [`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/list.md`](routes/number/list.md) | `GET /number` |
| [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` | | [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` |
| [`routes/order/list.md`](routes/order/list.md) | `GET /order` | | [`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 - that should loosly bound to services in /src/services
- for every service we have a CLI tester in /scripts/ - for every service we have a CLI tester in /scripts/
- all DB SQL code shall be in /src/db - 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/` - API docs: `@openapi` JSDoc on handlers → `npm run docs:api` → `docs/api/`

View File

@@ -79,6 +79,10 @@
{ {
"name": "Posterminal", "name": "Posterminal",
"description": "Company-scoped POS terminals" "description": "Company-scoped POS terminals"
},
{
"name": "Ledger",
"description": "Append-only ledger transactions"
} }
], ],
"paths": { "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": { "/health": {
"get": { "get": {
"tags": [ "tags": [
@@ -2848,7 +3193,7 @@
"Events" "Events"
], ],
"summary": "Subscribe to update signals (SSE)", "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-<sku>` | `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-<sku>`\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-<sku>` | `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-<sku>`\nevent name uses the concrete SKU (e.g. `article-42000045`).\n",
"security": [ "security": [
{ {
"cookieAuth": [] "cookieAuth": []
@@ -10613,6 +10958,83 @@
"description": "Base64 thumbhash of thumb preview" "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"
}
}
} }
} }
} }

View File

@@ -18,7 +18,7 @@ es.addEventListener('articles', () => { refetchArticleIndex(); });
es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); 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 Reconnect after changing the selected company (the stream is bound
to the company at connect time). to the company at connect time).

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -45,6 +45,7 @@ const options = {
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' }, { name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' }, { name: 'Image', description: 'Content-addressed AVIF by fullHash' },
{ name: 'Posterminal', description: 'Company-scoped POS terminals' }, { name: 'Posterminal', description: 'Company-scoped POS terminals' },
{ name: 'Ledger', description: 'Append-only ledger transactions' },
], ],
}, },
apis: [ apis: [

View File

@@ -663,6 +663,54 @@
* thumbHash: * thumbHash:
* type: string * type: string
* description: Base64 thumbhash of thumb preview * 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 {}; export {};

View File

@@ -287,7 +287,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
}); });
// ── LLMOrbiter setup ───────────────────────────────────────────── // ── 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 provider = new OpenRouterProvider({ apiKey });
const orbiter = new LLMOrbiter(provider, { const orbiter = new LLMOrbiter(provider, {
onStatus: (status) => { onStatus: (status) => {

342
src/api/routes/ledger.js Normal file
View File

@@ -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 });
}
});
}

43
src/db/ledger.js Normal file
View File

@@ -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<object>} 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<Array<object>>} 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<Array<object>>} List of ledger entries.
*/
export async function listBySku(googleAuthId, sku, transactionType) {
const store = await getStore();
return store.ledger.listBySku(googleAuthId, sku, transactionType);
}

View File

@@ -32,6 +32,7 @@ const STORE_NAMESPACES = [
'companysetting', 'companysetting',
'articlesupplier', 'articlesupplier',
'manifestattachment', 'manifestattachment',
'ledger',
]; ];
/** /**