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 +123 -37
- package/dist/beliq.js +19 -1
- package/dist/cli.js +4 -3
- package/dist/errors.js +11 -1
- package/dist/exit.js +3 -2
- package/dist/help.js +1 -1
- package/dist/index.js +7 -1
- package/dist/sevdesk.js +38 -15
- package/dist/state.js +25 -12
- package/dist/worker.js +148 -61
- package/package.json +6 -4
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,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
sevDesk
|
|
8
|
-
worker
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
rules
|
|
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=
|
|
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,
|
|
44
|
-
UBL (written to `./out`),
|
|
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
|
|
53
|
-
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 * * * *
|
|
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=
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* a raw XML body, `{ objects:
|
|
16
|
-
*
|
|
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
|
-
|
|
109
|
+
return all;
|
|
100
110
|
}
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
* A present-but-corrupt file throws IoError rather than silently
|
|
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 {
|
|
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
|
|
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
|
|
37
|
+
throw invalid(path, 'has neither processedIds nor a valid lastInvoiceId');
|
|
29
38
|
}
|
|
30
|
-
|
|
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(
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
8
|
-
const
|
|
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,
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
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
|
-
|
|
160
|
+
known,
|
|
101
161
|
listed: invoices.length,
|
|
102
162
|
fresh: fresh.length,
|
|
103
163
|
});
|
|
104
164
|
const counts = emptyCounts();
|
|
105
165
|
const outcomes = [];
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
173
|
+
result = await processInvoice(inv, config, deps);
|
|
112
174
|
}
|
|
113
175
|
catch (err) {
|
|
114
|
-
|
|
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 (
|
|
124
|
-
|
|
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 &&
|
|
188
|
+
if (!config.dryRun && (processed.size > state.processedIds.size || legacyMark !== undefined)) {
|
|
129
189
|
await saveState(config.stateFile, {
|
|
130
|
-
|
|
190
|
+
processedIds: processed,
|
|
131
191
|
lastPolledAt: new Date(now()).toISOString(),
|
|
132
192
|
});
|
|
133
193
|
}
|
|
134
|
-
const summary = formatSummary(counts,
|
|
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
|
|
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
|
|
163
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
172
|
-
|
|
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.
|
|
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
|
-
"
|
|
65
|
-
"
|
|
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
|
}
|