beliq-sevdesk 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,22 @@
1
1
  # beliq-sevdesk
2
2
 
3
3
  A small, self-hostable worker that polls [sevDesk](https://sevdesk.de) for your
4
- e-invoices, converts each one to the format a counterparty actually needs, and
5
- validates it against beliq's authority-pinned, drift-checked rules.
6
-
7
- sevDesk generates one configured e-invoice format and trusts its own output. This
8
- worker adds the two things it cannot:
9
-
10
- - **Conversion.** sevDesk emits its single configured format. When a counterparty
11
- needs a different one (ZUGFeRD to Peppol BIS UBL, or a French Factur-X profile),
12
- the worker retargets each invoice with `beliq convert`.
13
- - **An independent verdict.** `beliq validate` gives an authority-pinned second
14
- opinion sevDesk does not provide, catching profile-specific gaps such as BR-DE
15
- rules or a missing buyer reference / Leitweg-ID for public buyers.
4
+ e-invoices, validates each one against beliq's authority-pinned, drift-checked
5
+ rules, and converts it to the formats a counterparty needs.
6
+
7
+ sevDesk's API hands out an e-invoice as one XML document. On the account this
8
+ worker was tested against in October 2026, that is UN/CEFACT CII declaring the
9
+ XRechnung 3.0 specification. The worker adds two things:
10
+
11
+ - **An independent verdict.** sevDesk checks that the fields an e-invoice needs
12
+ are filled before the invoice may leave draft: it refuses one without a buyer
13
+ reference, for example. Its API has no validation endpoint and returns no
14
+ validation result. `beliq validate` checks the finished document against KoSIT's XRechnung
15
+ rules. One case from that account: sevDesk took an IBAN that is not a correct
16
+ IBAN, and beliq reported it as warning `BR-DE-19`.
17
+ - **Conversion.** When a counterparty needs another syntax or profile, the worker
18
+ converts each invoice with `beliq convert`. See
19
+ [What you can convert to](#what-you-can-convert-to).
16
20
 
17
21
  Your **sevDesk token never leaves your environment**: the worker runs where you
18
22
  run it, reads invoices directly from sevDesk, and only sends the invoice document
@@ -35,46 +39,113 @@ Set the two credentials, point it at the invoices you care about, and run it onc
35
39
  ```bash
36
40
  export SEVDESK_API_TOKEN=... # sevDesk: Settings -> Advanced -> API
37
41
  export BELIQ_API_KEY=... # beliq dashboard -> API Keys
38
- export SEVDESK_TARGET_FORMATS=peppol-bis
42
+ export SEVDESK_TARGET_FORMATS=xrechnung
39
43
 
40
44
  beliq-sevdesk --once
41
45
  ```
42
46
 
43
- That polls your Open invoices from the last 30 days, converts each to Peppol BIS
44
- UBL (written to `./out`), validates each one, prints a one-line summary, and exits
45
- with a code you can gate on. Leave `SEVDESK_TARGET_FORMATS` empty to run
47
+ That polls your Open invoices from the last 30 days, validates each one, converts
48
+ each to XRechnung in UBL syntax (written to `./out`), prints a one-line summary,
49
+ and exits with a code you can gate on. Leave `SEVDESK_TARGET_FORMATS` empty to run
46
50
  validation-only (no conversion, no files written).
47
51
 
48
52
  Copy [.env.example](.env.example) to `.env` for the full set of settings.
49
53
 
50
54
  ## How each invoice is processed
51
55
 
52
- For every invoice newer than the last one it saw (tracked by a persisted
53
- high-water-mark, so nothing is processed twice):
56
+ For every listed invoice the worker has not finished yet (it keeps the finished
57
+ ids in a state file, so nothing is processed twice):
54
58
 
55
59
  1. Pull the invoice XML from sevDesk (`GET /Invoice/{id}/getXml`).
56
- 2. Convert it to each configured target format and write the bytes to the output
57
- dir as `<invoiceNumber>-<target>.<ext>` (`.pdf` for facturx / zugferd, else
58
- `.xml`). Any elements a conversion could not carry across are logged.
59
- 3. Validate the source document and classify it: `valid`, `invalid`, or `error`
60
- (the pipeline threw before a verdict).
60
+ 2. Validate the document and classify it: `valid`, `invalid`, or `error` (no
61
+ verdict).
62
+ 3. Convert it to each configured target format and write the bytes to the output
63
+ dir as `<invoiceNumber>-<target>.<ext>`. The extension follows what beliq
64
+ returns: `.xml` for an XML document, `.pdf` for a PDF. Any elements a
65
+ conversion could not carry across are logged.
66
+
67
+ The worker validates the document sevDesk produced. It does not validate the
68
+ converted files, and neither does `beliq convert`: a conversion rewrites the
69
+ syntax and the specification id, not the content. Validate a converted file
70
+ yourself before you rely on it.
71
+
72
+ Each invoice stands alone. One that fails for a reason that can pass (sevDesk or
73
+ beliq unreachable, a spent quota, a wrong key, a full disk) is tried again on the
74
+ next poll, and the invoices after it are still processed.
75
+
76
+ If beliq refuses the document itself, because it cannot be read or because the
77
+ conversion is not possible, the invoice is reported once and not tried again. It
78
+ counts as `error`, unless validation already found it `invalid`. The log line
79
+ `validate.refused` or `convert.refused` carries beliq's status and error code.
80
+
81
+ An invoice that was still a draft while a newer one was processed is picked up
82
+ on the first poll that lists it.
83
+
84
+ Each invoice is checked once. On the tested account sevDesk locks an invoice when
85
+ it is opened, so its document cannot change afterwards.
86
+
87
+ sevDesk holds XML only for invoices created as e-invoices, and its invoice list
88
+ does not say which ones those are. So the worker asks for the XML of every listed
89
+ invoice. For a normal invoice sevDesk answers that it is not an electronic
90
+ invoice; the worker counts it as `skipped`, logs `invoice.skipped` once and does
91
+ not ask again. A skipped invoice does not change the exit code.
92
+
93
+ ### Which invoices are listed
94
+
95
+ Each poll lists the invoices in the configured status whose invoice date lies
96
+ within the last `SEVDESK_POLL_WINDOW_DAYS` days (default 30). sevDesk filters on
97
+ the invoice date, not on when the invoice was created or opened. An invoice dated
98
+ before the window is not listed, even when it was created or opened today:
99
+ opening a draft does not move its invoice date. Set `SEVDESK_POLL_WINDOW_DAYS=0`
100
+ to list every invoice in that status on every poll.
101
+
102
+ An invoice is seen only while it is in the configured status. When a part
103
+ payment is booked on an Open invoice, sevDesk moves it to another status and the
104
+ Open list no longer returns it.
105
+
106
+ ### What you can convert to
107
+
108
+ Measured with the XML of the tested account. Each of these came back with no lost
109
+ elements:
110
+
111
+ | Target | What comes back |
112
+ |---|---|
113
+ | `xrechnung` | UBL, declaring XRechnung 3.0 |
114
+ | `ubl` | UBL, declaring plain EN 16931 |
115
+ | `cii` | CII |
116
+ | `zugferd`, `facturx` | CII XML with that profile's specification id. Not a PDF: sevDesk's API gives the worker XML, and beliq builds a PDF only from a PDF source. `SEVDESK_TARGET_PROFILE` picks the profile |
61
117
 
62
- The high-water-mark advances only across a leading run of invoices that got a
63
- verdict. An invoice that errors (and any after it in the same batch) is left for
64
- the next poll, which is safe because reprocessing is idempotent.
118
+ `peppol-bis` does not work. Peppol BIS needs a Peppol endpoint identifier for the
119
+ seller and the buyer, sevDesk's XML carries neither, and beliq does not make one
120
+ up. beliq refuses the conversion (`CONVERSION_LOSSY_FAILCLOSED`) and the invoice
121
+ is reported as `error`.
65
122
 
66
123
  ## Run once, or as a daemon
67
124
 
68
125
  - `--once` polls a single time and exits. Use this from cron or CI.
69
126
  - With no `--once`, it loops, polling every `SEVDESK_POLL_INTERVAL_SECONDS`
70
- (default 300) until the process is stopped.
127
+ (default 300) until the process is stopped. A poll that sevDesk fails
128
+ (unreachable, or an error answer after the retries) is logged as `poll.error`
129
+ and the next poll runs on schedule. A local fault, such as an unreadable state
130
+ file, ends the worker with exit code 4.
131
+ - On SIGTERM or SIGINT the worker finishes the invoice it is working on, saves
132
+ its state and exits 0. Invoices it had not reached are picked up by the next
133
+ run.
71
134
  - `--dry-run` walks the full pipeline (real API calls, real verdicts) but writes
72
135
  no files and persists no state. Good for a first, safe look.
73
136
 
74
- A cron entry that runs it every 15 minutes:
137
+ A cron entry that runs it every 15 minutes. The settings come from a file only
138
+ root can read, because cron writes each command line it runs to the system log:
75
139
 
76
140
  ```cron
77
- */15 * * * * SEVDESK_API_TOKEN=... BELIQ_API_KEY=... SEVDESK_TARGET_FORMATS=peppol-bis /usr/bin/beliq-sevdesk --once >> /var/log/beliq-sevdesk.log 2>&1
141
+ */15 * * * * set -a; . /etc/beliq-sevdesk.env; set +a; /usr/bin/beliq-sevdesk --once >> /var/log/beliq-sevdesk.log 2>&1
142
+ ```
143
+
144
+ ```bash
145
+ # /etc/beliq-sevdesk.env, mode 600
146
+ SEVDESK_API_TOKEN=...
147
+ BELIQ_API_KEY=...
148
+ SEVDESK_TARGET_FORMATS=xrechnung
78
149
  ```
79
150
 
80
151
  ## Run it in a container
@@ -95,13 +166,13 @@ a single poll:
95
166
 
96
167
  ```bash
97
168
  docker run --rm \
98
- -e SEVDESK_API_TOKEN -e BELIQ_API_KEY -e SEVDESK_TARGET_FORMATS=peppol-bis \
169
+ -e SEVDESK_API_TOKEN -e BELIQ_API_KEY -e SEVDESK_TARGET_FORMATS=xrechnung \
99
170
  -v "$PWD/out:/app/out" -v "$PWD/state:/app/state" \
100
171
  ghcr.io/beliq-eu/beliq-sevdesk:latest --once
101
172
  ```
102
173
 
103
174
  Inside the image the state file defaults to `/app/state/state.json` and the
104
- output dir to `/app/out`; mount volumes there to persist the high-water-mark and
175
+ output dir to `/app/out`; mount volumes there to persist the state file and
105
176
  the converted documents across restarts. With no arguments the container loops as
106
177
  a daemon.
107
178
 
@@ -128,8 +199,8 @@ The body:
128
199
  ```json
129
200
  {
130
201
  "ok": false,
131
- "summary": "processed 2 invoice(s): 1 valid, 1 invalid, 0 error",
132
- "counts": { "valid": 1, "invalid": 1, "error": 0 },
202
+ "summary": "processed 2 invoice(s): 1 valid, 1 invalid, 0 error, 0 skipped",
203
+ "counts": { "valid": 1, "invalid": 1, "error": 0, "skipped": 0 },
133
204
  "invoices": [
134
205
  { "id": "10", "invoiceNumber": "INV-10", "classification": "valid" },
135
206
  { "id": "11", "invoiceNumber": "INV-11", "classification": "invalid" }
@@ -145,7 +216,9 @@ The exit code always reflects the invoices, not the notification.
145
216
  ## Configuration
146
217
 
147
218
  Every setting is read from the environment; the flags below override the matching
148
- variable.
219
+ variable. Pass the two credentials through the environment: a value given with
220
+ `--sevdesk-token` or `--api-key` is on the command line, where every user on the
221
+ host can read it in the process list.
149
222
 
150
223
  | Variable | Flag | Default | Description |
151
224
  |---|---|---|---|
@@ -155,7 +228,7 @@ variable.
155
228
  | `SEVDESK_TARGET_PROFILE` | | (none) | Factur-X / ZUGFeRD profile, for a facturx / zugferd target. |
156
229
  | `SEVDESK_INVOICE_STATUS` | `--status` | `Open` | `Draft`, `Open`, `Paid`, or a numeric code. |
157
230
  | `SEVDESK_POLL_WINDOW_DAYS` | `--poll-window-days` | `30` | Only fetch invoices dated within n days back; `0` disables. |
158
- | `SEVDESK_STATE_FILE` | `--state` | `.beliq-sevdesk-state.json` | The persisted high-water-mark. |
231
+ | `SEVDESK_STATE_FILE` | `--state` | `.beliq-sevdesk-state.json` | The state file: the ids of the invoices already processed. |
159
232
  | `SEVDESK_OUTPUT_DIR` | `--output` | `./out` | Where converted documents are written. |
160
233
  | `SEVDESK_POLL_INTERVAL_SECONDS` | `--interval` | `300` | Seconds between polls in daemon mode. |
161
234
  | `SEVDESK_PAGE_SIZE` | | `100` | Page size for the invoice listing. |
@@ -176,15 +249,28 @@ Meaningful with `--once`, so cron and CI can act on the result:
176
249
 
177
250
  | Code | Meaning |
178
251
  |---|---|
179
- | 0 | every processed invoice was valid, or there was nothing to do |
252
+ | 0 | every processed invoice was valid or skipped, or there was nothing to do |
180
253
  | 1 | at least one invoice failed validation |
181
254
  | 2 | config / usage error (missing token or key, bad flag or value) |
182
- | 3 | a sevDesk or beliq API error, or an invoice that errored mid-pipeline |
255
+ | 3 | a sevDesk or beliq API error, an invoice that errored mid-pipeline, or a document beliq refused to validate or convert |
183
256
  | 4 | I/O error (unreadable state file, unwritable output) |
184
257
 
185
258
  An error (code 3) outranks an invalid document (code 1): not getting a verdict is
186
259
  worse than getting a bad one.
187
260
 
261
+ ## Upgrading from 0.2.x
262
+
263
+ 0.2.x stored one number in the state file: the highest invoice id it had
264
+ processed. The first poll after the upgrade reads that number, marks every listed
265
+ invoice with an id at or below it as processed, and rewrites the file as a list
266
+ of ids. Nothing needs to be done by hand.
267
+
268
+ One thing to know. 0.2.x skipped an invoice that was still a draft while a newer
269
+ invoice was processed. The old file cannot tell such an invoice from a processed
270
+ one, so the upgrade marks it as processed too. To have every invoice in the
271
+ current poll window processed again, delete the state file before the first run.
272
+ That validates and converts those invoices again and uses beliq quota for each.
273
+
188
274
  ## Logging
189
275
 
190
276
  Structured events are written to stderr, one JSON object per line, for log
package/dist/beliq.js CHANGED
@@ -1,4 +1,22 @@
1
- import { Beliq } from '@beliq/sdk';
1
+ import { Beliq, BeliqApiError } from '@beliq/sdk';
2
2
  export function makeBeliqClient(config) {
3
3
  return new Beliq({ apiKey: config.beliqApiKey, baseUrl: config.beliqBaseUrl, auth: config.beliqAuth });
4
4
  }
5
+ const HTTP_PAYLOAD_TOO_LARGE = 413;
6
+ const HTTP_UNPROCESSABLE = 422;
7
+ /**
8
+ * True when beliq refused the document itself, so the same bytes get the same
9
+ * answer on every later poll. On /v1/validate and /v1/convert that is a 422
10
+ * (unprocessable document, or a conversion the engine will not do), a 413 (the
11
+ * document is over the size cap) and PARSE_FAILED, which also arrives as a 400.
12
+ * The status decides, not the code: VALIDATION_ERROR is a 400 for a rejected
13
+ * request and a 422 for a rejected document. Every other 4xx is about the
14
+ * caller (INVALID_API_KEY 403, QUOTA_EXCEEDED and RATE_LIMITED 429) and clears
15
+ * without the document changing. Codes and statuses per route:
16
+ * https://api.beliq.eu/openapi.json
17
+ */
18
+ export function isDocumentRefusal(err) {
19
+ if (!(err instanceof BeliqApiError))
20
+ return false;
21
+ return err.status === HTTP_UNPROCESSABLE || err.status === HTTP_PAYLOAD_TOO_LARGE || err.code === 'PARSE_FAILED';
22
+ }
package/dist/cli.js CHANGED
@@ -11,9 +11,10 @@ import { HELP, version } from './help.js';
11
11
  * The entry: parse argv, handle --help / --version, resolve config, build the
12
12
  * real sevDesk + beliq clients, run the worker, and map every error class to its
13
13
  * exit code (see EXIT). Pure in its logger + env seams so it can be driven from a
14
- * test without touching real streams. Returns the process exit code.
14
+ * test without touching real streams. Returns the process exit code. Aborting
15
+ * `stop` ends the worker after the invoice in hand.
15
16
  */
16
- export async function main(argv, log, env = process.env) {
17
+ export async function main(argv, log, env = process.env, stop) {
17
18
  let config;
18
19
  try {
19
20
  const args = parseArgs(argv);
@@ -41,7 +42,7 @@ export async function main(argv, log, env = process.env) {
41
42
  maxRetries: config.maxRetries,
42
43
  });
43
44
  const beliq = makeBeliqClient(config);
44
- return await runWorker(config, { sevdesk, beliq, log });
45
+ return await runWorker(config, { sevdesk, beliq, log, stop });
45
46
  }
46
47
  catch (err) {
47
48
  if (err instanceof ConfigError) {
package/dist/errors.js CHANGED
@@ -7,9 +7,19 @@ export class IoError extends Error {
7
7
  /** A non-2xx sevDesk response (after retries) or a response body we could not parse. Maps to EXIT.API. */
8
8
  export class SevDeskApiError extends Error {
9
9
  status;
10
- constructor(message, status) {
10
+ /** What sevDesk sent with a non-2xx answer, cut to a loggable length. Empty when it sent nothing. */
11
+ body;
12
+ constructor(message, status, body = '') {
11
13
  super(message);
12
14
  this.name = 'SevDeskApiError';
13
15
  this.status = status;
16
+ this.body = body;
17
+ }
18
+ }
19
+ /** sevDesk has no XML for this invoice: it was not created as an e-invoice. Nothing to validate or convert. */
20
+ export class NotAnEInvoiceError extends SevDeskApiError {
21
+ constructor(message, status, body = '') {
22
+ super(message, status, body);
23
+ this.name = 'NotAnEInvoiceError';
14
24
  }
15
25
  }
package/dist/exit.js CHANGED
@@ -11,13 +11,14 @@ export const EXIT = {
11
11
  IO: 4,
12
12
  };
13
13
  export function emptyCounts() {
14
- return { valid: 0, invalid: 0, error: 0 };
14
+ return { valid: 0, invalid: 0, error: 0, skipped: 0 };
15
15
  }
16
16
  /**
17
17
  * The run's exit code from the tallied outcomes. An error (we never got a
18
18
  * verdict) is worse than an invalid document (a verdict of non-compliant), so it
19
19
  * wins: EXIT.API > EXIT.INVALID > EXIT.OK. This is a faithful superset of the
20
- * CLI/Action contract, where "any invoice failed" maps to a non-zero exit.
20
+ * CLI/Action contract, where "any invoice failed" maps to a non-zero exit. A
21
+ * skipped invoice is not a failure and does not move the exit code.
21
22
  */
22
23
  export function summaryExitCode(counts) {
23
24
  if (counts.error > 0)
package/dist/help.js CHANGED
@@ -19,7 +19,7 @@ Options:
19
19
  --target-format <csv> beliq convert targets, comma-separated (default: SEVDESK_TARGET_FORMATS)
20
20
  --status <s> Draft | Open | Paid, or a numeric code (default: SEVDESK_INVOICE_STATUS, else Open)
21
21
  --poll-window-days <n> only fetch invoices dated within n days back; 0 disables (default: 30)
22
- --state <path> high-water-mark file (default: SEVDESK_STATE_FILE, else .beliq-sevdesk-state.json)
22
+ --state <path> state file, the invoice ids already processed (default: SEVDESK_STATE_FILE, else .beliq-sevdesk-state.json)
23
23
  --output <dir> where converted documents are written (default: SEVDESK_OUTPUT_DIR, else ./out)
24
24
  --interval <seconds> seconds between polls in daemon mode (default: 300)
25
25
  --sevdesk-token <token> sevDesk API token (default: SEVDESK_API_TOKEN)
package/dist/index.js CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from './cli.js';
3
3
  import { nodeLogger } from './log.js';
4
- main(process.argv.slice(2), nodeLogger()).then((code) => {
4
+ // Without a handler SIGTERM ends the process at once, mid-invoice and before the
5
+ // state is saved. As PID 1 in a container it would not end the process at all.
6
+ const stopping = new AbortController();
7
+ for (const signal of ['SIGTERM', 'SIGINT']) {
8
+ process.once(signal, () => stopping.abort());
9
+ }
10
+ main(process.argv.slice(2), nodeLogger(), process.env, stopping.signal).then((code) => {
5
11
  process.exitCode = code;
6
12
  }, (err) => {
7
13
  process.stderr.write(`beliq-sevdesk: fatal: ${err.message}\n`);
package/dist/sevdesk.js CHANGED
@@ -1,20 +1,30 @@
1
- import { SevDeskApiError } from './errors.js';
1
+ import { NotAnEInvoiceError, SevDeskApiError } from './errors.js';
2
2
  /** A hard ceiling on pages walked per poll, so a misconfigured window can't loop forever. */
3
3
  const MAX_PAGES = 100;
4
4
  /** Per-attempt deadline so a stalled sevDesk connection aborts instead of hanging the poll. */
5
5
  const REQUEST_TIMEOUT_MS = 30_000;
6
+ /** How much of a sevDesk error body goes into the error message and the log line. */
7
+ const ERROR_BODY_MAX_CHARS = 300;
8
+ const HTTP_BAD_REQUEST = 400;
9
+ /**
10
+ * What sevDesk puts in `error.message`, with a 400, when getXml is asked for an
11
+ * invoice that was not created as an e-invoice. Its API documentation
12
+ * (https://api.sevdesk.de/openapi.yaml) lists that 400 as "Invoice was not
13
+ * found", but an unknown id answers 404. Both recorded from a live account:
14
+ * test/fixtures/sevdesk/.
15
+ */
16
+ const NOT_AN_E_INVOICE_MESSAGE = 'This invoice is not an electronic invoice';
6
17
  const encoder = new TextEncoder();
7
18
  const decoder = new TextDecoder('utf-8');
8
19
  function looksLikeXml(s) {
9
20
  return s.trimStart().startsWith('<');
10
21
  }
11
22
  /**
12
- * Pull the XML out of a getXml response. sevDesk's exact envelope for this
13
- * endpoint is not publicly pinned (the live round-trip that confirms it is
14
- * operator-gated), so this handles the documented-plausible shapes in one place:
15
- * a raw XML body, `{ objects: "<xml>" }`, or `{ objects: { content, base64 } }`,
16
- * with the payload optionally base64-encoded. If a live response differs, this
17
- * is the single function to adjust.
23
+ * Pull the XML out of a getXml response. sevDesk documents the answer as
24
+ * `{ objects: "<xml>" }` (https://api.sevdesk.de/openapi.yaml), and a live
25
+ * account answers exactly that: test/fixtures/sevdesk/getXml-e-invoice.json.
26
+ * The other shapes handled here (a raw XML body, `{ objects: { content, base64 } }`,
27
+ * a base64 payload) have not been seen from sevDesk.
18
28
  */
19
29
  export function extractXml(contentType, text) {
20
30
  if (contentType.includes('xml') || looksLikeXml(text))
@@ -96,13 +106,26 @@ export class SevDeskClient {
96
106
  for (const o of objects)
97
107
  all.push(normalizeInvoice(o));
98
108
  if (objects.length < limit)
99
- break;
109
+ return all;
100
110
  }
101
- return all;
111
+ // Returning what was read so far would leave the invoices beyond the cap
112
+ // unprocessed with nothing to show for it.
113
+ throw new SevDeskApiError(`sevDesk invoice list did not end after ${MAX_PAGES} pages of ${limit}; ` +
114
+ 'narrow it with SEVDESK_POLL_WINDOW_DAYS or raise SEVDESK_PAGE_SIZE', 200);
102
115
  }
103
116
  async getInvoiceXml(id) {
104
- const { text, contentType } = await this.#request('GET', `/Invoice/${encodeURIComponent(id)}/getXml`);
105
- return extractXml(contentType, text);
117
+ try {
118
+ const { text, contentType } = await this.#request('GET', `/Invoice/${encodeURIComponent(id)}/getXml`);
119
+ return extractXml(contentType, text);
120
+ }
121
+ catch (err) {
122
+ if (err instanceof SevDeskApiError &&
123
+ err.status === HTTP_BAD_REQUEST &&
124
+ err.body.includes(NOT_AN_E_INVOICE_MESSAGE)) {
125
+ throw new NotAnEInvoiceError(err.message, err.status, err.body);
126
+ }
127
+ throw err;
128
+ }
106
129
  }
107
130
  #parseObjects(text) {
108
131
  let parsed;
@@ -129,7 +152,7 @@ export class SevDeskClient {
129
152
  const controller = new AbortController();
130
153
  const timer = setTimeout(() => controller.abort(), this.#requestTimeoutMs);
131
154
  let res;
132
- let text = '';
155
+ let text;
133
156
  try {
134
157
  res = await this.#fetch(url.toString(), {
135
158
  method,
@@ -137,8 +160,7 @@ export class SevDeskClient {
137
160
  signal: controller.signal,
138
161
  });
139
162
  // Read the body under the same deadline so a stalled stream also aborts.
140
- if (res.ok)
141
- text = await res.text();
163
+ text = await res.text();
142
164
  }
143
165
  catch (err) {
144
166
  lastError = controller.signal.aborted
@@ -170,7 +192,8 @@ export class SevDeskClient {
170
192
  throw new SevDeskApiError(`sevDesk ${method} ${path} returned an HTML 404: ${this.#baseUrl} is not the REST API. ` +
171
193
  'The REST base is https://my.sevdesk.de/api/v1; check SEVDESK_BASE_URL.', res.status);
172
194
  }
173
- throw new SevDeskApiError(`sevDesk ${method} ${path} failed with status ${res.status}`, res.status);
195
+ const body = text.trim().slice(0, ERROR_BODY_MAX_CHARS);
196
+ throw new SevDeskApiError(`sevDesk ${method} ${path} failed with status ${res.status}${body ? `: ${body}` : ''}`, res.status, body);
174
197
  }
175
198
  // Unreachable: the loop either returns or throws. Satisfies noImplicitReturns.
176
199
  throw new SevDeskApiError(`sevDesk ${method} ${path} exhausted retries`, 0);
package/dist/state.js CHANGED
@@ -1,10 +1,12 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
1
+ import { readFile, rename, writeFile } from 'node:fs/promises';
2
2
  import { IoError } from './errors.js';
3
- const EMPTY = { lastInvoiceId: 0 };
3
+ function invalid(path, what) {
4
+ return new IoError(`state file ${path} ${what}; fix or delete it to start fresh`);
5
+ }
4
6
  /**
5
- * Load the high-water-mark. A missing file is the first-run case (start from 0).
6
- * A present-but-corrupt file throws IoError rather than silently resetting to 0,
7
- * which would reprocess the entire account.
7
+ * Load the state. A missing file is the first-run case (nothing processed).
8
+ * A present-but-corrupt file throws IoError rather than silently starting
9
+ * empty, which would reprocess the entire account.
8
10
  */
9
11
  export async function loadState(path) {
10
12
  let text;
@@ -13,7 +15,7 @@ export async function loadState(path) {
13
15
  }
14
16
  catch (err) {
15
17
  if (err.code === 'ENOENT')
16
- return { ...EMPTY };
18
+ return { processedIds: new Set() };
17
19
  throw new IoError(`could not read state file ${path}: ${err.message}`);
18
20
  }
19
21
  let parsed;
@@ -21,18 +23,29 @@ export async function loadState(path) {
21
23
  parsed = JSON.parse(text);
22
24
  }
23
25
  catch {
24
- throw new IoError(`state file ${path} is not valid JSON; fix or delete it to start fresh`);
26
+ throw invalid(path, 'is not valid JSON');
27
+ }
28
+ const { processedIds, lastInvoiceId, lastPolledAt } = (parsed ?? {});
29
+ const polledAt = typeof lastPolledAt === 'string' ? lastPolledAt : undefined;
30
+ if (processedIds !== undefined) {
31
+ if (!Array.isArray(processedIds) || processedIds.some((id) => typeof id !== 'string')) {
32
+ throw invalid(path, 'has a processedIds that is not a list of id strings');
33
+ }
34
+ return { processedIds: new Set(processedIds), lastPolledAt: polledAt };
25
35
  }
26
- const lastInvoiceId = parsed?.lastInvoiceId;
27
36
  if (typeof lastInvoiceId !== 'number' || !Number.isFinite(lastInvoiceId) || lastInvoiceId < 0) {
28
- throw new IoError(`state file ${path} has no valid lastInvoiceId; fix or delete it to start fresh`);
37
+ throw invalid(path, 'has neither processedIds nor a valid lastInvoiceId');
29
38
  }
30
- const lastPolledAt = parsed.lastPolledAt;
31
- return { lastInvoiceId, lastPolledAt: typeof lastPolledAt === 'string' ? lastPolledAt : undefined };
39
+ return { processedIds: new Set(), legacyLastInvoiceId: lastInvoiceId, lastPolledAt: polledAt };
32
40
  }
33
41
  export async function saveState(path, state) {
42
+ const processedIds = [...state.processedIds].sort((a, b) => Number(a) - Number(b));
43
+ // Written beside the target and renamed over it, so a crash mid-write leaves
44
+ // the previous file whole. A torn state file would stop every later run.
45
+ const partial = `${path}.tmp`;
34
46
  try {
35
- await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
47
+ await writeFile(partial, `${JSON.stringify({ processedIds, lastPolledAt: state.lastPolledAt }, null, 2)}\n`);
48
+ await rename(partial, path);
36
49
  }
37
50
  catch (err) {
38
51
  throw new IoError(`could not write state file ${path}: ${err.message}`);
package/dist/worker.js CHANGED
@@ -1,11 +1,20 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { IoError } from './errors.js';
4
- import { emptyCounts, summaryExitCode } from './exit.js';
3
+ import { isDocumentRefusal } from './beliq.js';
4
+ import { IoError, NotAnEInvoiceError, SevDeskApiError } from './errors.js';
5
+ import { EXIT, emptyCounts, summaryExitCode } from './exit.js';
5
6
  import { loadState, saveState } from './state.js';
6
7
  import { notify } from './notify.js';
7
- /** Convert targets that produce a hybrid PDF rather than a standalone XML document. */
8
- const PDF_TARGETS = new Set(['facturx', 'zugferd']);
8
+ /** Convert targets that take a Factur-X / ZUGFeRD profile. */
9
+ const PROFILED_TARGETS = new Set(['facturx', 'zugferd']);
10
+ /**
11
+ * The file extension for what beliq sent back. A conversion to facturx or
12
+ * zugferd is a PDF only when the source was a hybrid PDF. sevDesk's getXml gives
13
+ * XML, and from XML beliq returns XML, so the target's name does not decide this.
14
+ */
15
+ function extensionFor(contentType) {
16
+ return contentType.includes('pdf') ? 'pdf' : 'xml';
17
+ }
9
18
  /** Seconds in a day, for the poll-window date filter. */
10
19
  const SECONDS_PER_DAY = 86_400;
11
20
  function safeName(inv) {
@@ -30,22 +39,69 @@ async function writeOutput(dir, name, bytes) {
30
39
  }
31
40
  }
32
41
  /**
33
- * Run one invoice through the pipeline: pull its XML, convert it to each
34
- * configured target (writing the bytes out), then validate the source document
35
- * for an independent authority-pinned verdict sevDesk does not provide. Returns
36
- * the classification; a throw here is caught by the caller and counted as an
37
- * error (no verdict), leaving the high-water-mark short of this invoice so it is
38
- * retried next poll.
42
+ * Run one invoice through the pipeline: pull its XML, validate it for an
43
+ * independent authority-pinned verdict sevDesk does not provide, then convert it
44
+ * to each configured target and write the bytes out. Validation runs first so a
45
+ * conversion beliq refuses cannot cost the verdict.
46
+ *
47
+ * An invoice sevDesk holds no XML for is skipped for good. A refusal about the
48
+ * document itself is final too: the same bytes get the same answer next poll. Anything else that throws (sevDesk, the network, a spent
49
+ * quota, a full disk) propagates, and the caller leaves the invoice for the
50
+ * next poll.
39
51
  */
40
52
  async function processInvoice(inv, config, deps) {
41
- const xml = await deps.sevdesk.getInvoiceXml(inv.id);
42
- for (const target of config.targetFormats) {
43
- const result = await deps.beliq.convert(xml, {
44
- targetFormat: target,
45
- targetProfile: PDF_TARGETS.has(target) ? config.targetProfile : undefined,
53
+ let xml;
54
+ try {
55
+ xml = await deps.sevdesk.getInvoiceXml(inv.id);
56
+ }
57
+ catch (err) {
58
+ if (!(err instanceof NotAnEInvoiceError))
59
+ throw err;
60
+ deps.log.info('invoice.skipped', { id: inv.id, number: inv.invoiceNumber, reason: 'not an e-invoice' });
61
+ return { classification: 'skipped', final: true };
62
+ }
63
+ let valid;
64
+ try {
65
+ const verdict = await deps.beliq.validate(xml, {});
66
+ valid = verdict.valid;
67
+ deps.log.info('validate', {
68
+ id: inv.id,
69
+ number: inv.invoiceNumber,
70
+ valid,
71
+ errors: verdict.errors?.length ?? 0,
72
+ warnings: verdict.warnings?.length ?? 0,
73
+ classification: valid ? 'valid' : 'invalid',
74
+ });
75
+ }
76
+ catch (err) {
77
+ if (!isDocumentRefusal(err))
78
+ throw err;
79
+ deps.log.error('validate.refused', {
80
+ id: inv.id,
81
+ number: inv.invoiceNumber,
82
+ status: err.status,
83
+ code: err.code,
84
+ message: err.message,
46
85
  });
47
- const ext = PDF_TARGETS.has(target) ? 'pdf' : 'xml';
48
- const file = `${safeName(inv)}-${target}.${ext}`;
86
+ return { classification: 'error', final: true };
87
+ }
88
+ let refused = false;
89
+ for (const target of config.targetFormats) {
90
+ let result;
91
+ try {
92
+ result = await deps.beliq.convert(xml, {
93
+ targetFormat: target,
94
+ targetProfile: PROFILED_TARGETS.has(target) ? config.targetProfile : undefined,
95
+ });
96
+ }
97
+ catch (err) {
98
+ if (!isDocumentRefusal(err))
99
+ throw err;
100
+ refused = true;
101
+ deps.log.error('convert.refused', { id: inv.id, target, status: err.status, code: err.code, message: err.message });
102
+ continue;
103
+ }
104
+ const file = `${safeName(inv)}-${target}.${extensionFor(result.contentType)}`;
49
105
  const lostElements = result.meta.lostElementsCount ?? 0;
50
106
  if (config.dryRun) {
51
107
  deps.log.info('convert.dryRun', { id: inv.id, target, file, lostElements });
@@ -55,29 +111,21 @@ async function processInvoice(inv, config, deps) {
55
111
  deps.log.info('convert', { id: inv.id, target, file, lostElements });
56
112
  }
57
113
  }
58
- const verdict = await deps.beliq.validate(xml, {});
59
- const classification = verdict.valid ? 'valid' : 'invalid';
60
- deps.log.info('validate', {
61
- id: inv.id,
62
- number: inv.invoiceNumber,
63
- valid: verdict.valid,
64
- errors: verdict.errors?.length ?? 0,
65
- warnings: verdict.warnings?.length ?? 0,
66
- classification,
67
- });
68
- return classification;
114
+ // An invalid document keeps its verdict. A valid one that beliq would not
115
+ // convert is an error: the file the operator asked for does not exist.
116
+ if (!valid)
117
+ return { classification: 'invalid', final: true };
118
+ return { classification: refused ? 'error' : 'valid', final: true };
69
119
  }
70
120
  function formatSummary(counts, fresh, dryRun) {
71
121
  const prefix = dryRun ? '[dry-run] ' : '';
72
- return `${prefix}processed ${fresh} invoice(s): ${counts.valid} valid, ${counts.invalid} invalid, ${counts.error} error`;
122
+ return `${prefix}processed ${fresh} invoice(s): ${counts.valid} valid, ${counts.invalid} invalid, ${counts.error} error, ${counts.skipped} skipped`;
73
123
  }
74
124
  /**
75
- * Poll sevDesk once: fetch invoices in the configured status/window, process
76
- * only those newer than the high-water-mark (in ascending id order so the mark
77
- * advances monotonically and dedupes by id), and persist the advanced mark. The
78
- * mark advances only across the contiguous error-free prefix: an invoice that
79
- * errors (and every invoice after it in this batch) is left for the next poll,
80
- * which is safe because reprocessing is idempotent.
125
+ * Poll sevDesk once: fetch invoices in the configured status/window and process
126
+ * every one whose id is not yet in the processed set, in ascending id order.
127
+ * Each invoice stands alone: one that errors is left out of the set and comes
128
+ * back next poll, and the invoices after it are still processed.
81
129
  */
82
130
  export async function pollOnce(config, deps) {
83
131
  const now = deps.now ?? (() => Date.now());
@@ -91,50 +139,62 @@ export async function pollOnce(config, deps) {
91
139
  startDate,
92
140
  pageSize: config.pageSize,
93
141
  });
94
- const fresh = invoices
95
- .map((inv) => ({ inv, idNum: Number(inv.id) }))
96
- .filter(({ idNum }) => Number.isFinite(idNum) && idNum > state.lastInvoiceId)
97
- .sort((a, b) => a.idNum - b.idNum);
142
+ const processed = new Set(state.processedIds);
143
+ const legacyMark = state.legacyLastInvoiceId;
144
+ if (legacyMark !== undefined) {
145
+ // A pre-0.3.0 state file holds one number. Every listed invoice at or below
146
+ // it counts as done, which includes drafts the old worker skipped: the mark
147
+ // cannot tell the two apart.
148
+ for (const inv of invoices) {
149
+ if (Number(inv.id) <= legacyMark)
150
+ processed.add(inv.id);
151
+ }
152
+ }
153
+ const known = processed.size;
154
+ // Keyed by id: offset paging can hand back one invoice on two pages.
155
+ const fresh = [...new Map(invoices.map((inv) => [inv.id, inv])).values()]
156
+ .filter((inv) => !processed.has(inv.id))
157
+ .sort((a, b) => Number(a.id) - Number(b.id));
98
158
  deps.log.info('poll', {
99
159
  status: config.status,
100
- since: state.lastInvoiceId,
160
+ known,
101
161
  listed: invoices.length,
102
162
  fresh: fresh.length,
103
163
  });
104
164
  const counts = emptyCounts();
105
165
  const outcomes = [];
106
- let highWater = state.lastInvoiceId;
107
- let blocked = false;
108
- for (const { inv, idNum } of fresh) {
109
- let classification;
166
+ for (const inv of fresh) {
167
+ if (deps.stop?.aborted) {
168
+ deps.log.info('poll.stopped', { unprocessed: fresh.length - outcomes.length });
169
+ break;
170
+ }
171
+ let result;
110
172
  try {
111
- classification = await processInvoice(inv, config, deps);
173
+ result = await processInvoice(inv, config, deps);
112
174
  }
113
175
  catch (err) {
114
- classification = 'error';
176
+ result = { classification: 'error', final: false };
115
177
  deps.log.error('invoice.error', {
116
178
  id: inv.id,
117
179
  number: inv.invoiceNumber,
118
180
  message: err.message,
119
181
  });
120
182
  }
121
- counts[classification]++;
122
- outcomes.push({ id: inv.id, invoiceNumber: inv.invoiceNumber, classification });
123
- if (classification === 'error')
124
- blocked = true;
125
- else if (!blocked)
126
- highWater = idNum;
183
+ counts[result.classification]++;
184
+ outcomes.push({ id: inv.id, invoiceNumber: inv.invoiceNumber, classification: result.classification });
185
+ if (result.final)
186
+ processed.add(inv.id);
127
187
  }
128
- if (!config.dryRun && highWater > state.lastInvoiceId) {
188
+ if (!config.dryRun && (processed.size > state.processedIds.size || legacyMark !== undefined)) {
129
189
  await saveState(config.stateFile, {
130
- lastInvoiceId: highWater,
190
+ processedIds: processed,
131
191
  lastPolledAt: new Date(now()).toISOString(),
132
192
  });
133
193
  }
134
- const summary = formatSummary(counts, fresh.length, config.dryRun);
194
+ const summary = formatSummary(counts, outcomes.length, config.dryRun);
135
195
  deps.log.summary(summary);
136
196
  await maybeNotify(config, deps, counts, outcomes, summary, now);
137
- return { counts, processedTo: highWater };
197
+ return { counts };
138
198
  }
139
199
  /**
140
200
  * Fire the notify webhook when configured. Skipped in a dry run (a dry run has no
@@ -157,19 +217,46 @@ async function maybeNotify(config, deps, counts, outcomes, summary, now) {
157
217
  };
158
218
  await notify(url, report, { fetch: deps.fetch ?? fetch, log: deps.log });
159
219
  }
220
+ /** A timer that ends early when the worker is told to stop. */
221
+ function sleepUntilStopped(ms, stop) {
222
+ return new Promise((resolve) => {
223
+ if (stop?.aborted)
224
+ return resolve();
225
+ const done = () => {
226
+ clearTimeout(timer);
227
+ stop?.removeEventListener('abort', done);
228
+ resolve();
229
+ };
230
+ const timer = setTimeout(done, ms);
231
+ stop?.addEventListener('abort', done, { once: true });
232
+ });
233
+ }
160
234
  /**
161
235
  * Run the worker. With --once, poll a single time and return the exit code (the
162
- * CI/cron contract). Otherwise loop forever, polling every interval, until the
163
- * process is signalled.
236
+ * CI/cron contract). Otherwise loop, polling every interval, until deps.stop is
237
+ * aborted. In the loop a poll that sevDesk fails (unreachable, or an error
238
+ * answer after retries) is logged and the next poll runs on schedule. A local
239
+ * fault, such as an unreadable state file, still ends the worker: polling again
240
+ * cannot fix it.
164
241
  */
165
242
  export async function runWorker(config, deps) {
166
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
243
+ const sleep = deps.sleep ?? ((ms) => sleepUntilStopped(ms, deps.stop));
167
244
  if (config.once) {
168
245
  const { counts } = await pollOnce(config, deps);
169
246
  return summaryExitCode(counts);
170
247
  }
171
- for (;;) {
172
- await pollOnce(config, deps);
248
+ while (!deps.stop?.aborted) {
249
+ try {
250
+ await pollOnce(config, deps);
251
+ }
252
+ catch (err) {
253
+ if (!(err instanceof SevDeskApiError))
254
+ throw err;
255
+ deps.log.error('poll.error', { status: err.status, message: err.message });
256
+ }
257
+ if (deps.stop?.aborted)
258
+ break;
173
259
  await sleep(config.intervalSeconds * 1000);
174
260
  }
261
+ return EXIT.OK;
175
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beliq-sevdesk",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Self-hostable worker that polls sevDesk for e-invoices, converts each to the formats a counterparty needs, and validates them against beliq's authority-pinned, drift-checked rules. The sevDesk token never leaves your environment.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,6 +51,7 @@
51
51
  "typecheck": "tsc --noEmit",
52
52
  "lint": "eslint src",
53
53
  "lint:fix": "eslint src --fix",
54
+ "pretest": "npm run build",
54
55
  "test": "vitest run --exclude test/integration.test.ts",
55
56
  "test:integration": "vitest run test/integration.test.ts",
56
57
  "scrub:check": "bash scripts/scrub-check.sh",
@@ -60,11 +61,12 @@
60
61
  "@beliq/sdk": "^0.4.0"
61
62
  },
62
63
  "devDependencies": {
64
+ "@eslint/js": "^10.0.1",
63
65
  "@types/node": "^20.14.0",
64
- "@typescript-eslint/eslint-plugin": "^8.0.0",
65
- "@typescript-eslint/parser": "^8.0.0",
66
- "eslint": "^8.57.0",
66
+ "eslint": "^10.11.0",
67
+ "globals": "^17.12.0",
67
68
  "typescript": "^5.5.4",
69
+ "typescript-eslint": "^8.70.1",
68
70
  "vitest": "^4.0.0"
69
71
  }
70
72
  }