beliq-sevdesk 0.1.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/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/args.js +69 -0
- package/dist/beliq.js +4 -0
- package/dist/cli.js +66 -0
- package/dist/config.js +89 -0
- package/dist/errors.js +15 -0
- package/dist/exit.js +28 -0
- package/dist/help.js +39 -0
- package/dist/index.js +9 -0
- package/dist/log.js +10 -0
- package/dist/sevdesk.js +167 -0
- package/dist/state.js +40 -0
- package/dist/worker.js +149 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 beliq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# beliq-sevdesk
|
|
2
|
+
|
|
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.
|
|
16
|
+
|
|
17
|
+
Your **sevDesk token never leaves your environment**: the worker runs where you
|
|
18
|
+
run it, reads invoices directly from sevDesk, and only sends the invoice document
|
|
19
|
+
to beliq for validation and conversion.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g beliq-sevdesk
|
|
25
|
+
# or run without installing:
|
|
26
|
+
npx beliq-sevdesk --once
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires Node.js >= 20.15.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
Set the two credentials, point it at the invoices you care about, and run it once:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export SEVDESK_API_TOKEN=... # sevDesk: Settings -> Advanced -> API
|
|
37
|
+
export BELIQ_API_KEY=... # beliq dashboard -> API Keys
|
|
38
|
+
export SEVDESK_TARGET_FORMATS=peppol-bis
|
|
39
|
+
|
|
40
|
+
beliq-sevdesk --once
|
|
41
|
+
```
|
|
42
|
+
|
|
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
|
|
46
|
+
validation-only (no conversion, no files written).
|
|
47
|
+
|
|
48
|
+
Copy [.env.example](.env.example) to `.env` for the full set of settings.
|
|
49
|
+
|
|
50
|
+
## How each invoice is processed
|
|
51
|
+
|
|
52
|
+
For every invoice newer than the last one it saw (tracked by a persisted
|
|
53
|
+
high-water-mark, so nothing is processed twice):
|
|
54
|
+
|
|
55
|
+
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).
|
|
61
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
## Run once, or as a daemon
|
|
67
|
+
|
|
68
|
+
- `--once` polls a single time and exits. Use this from cron or CI.
|
|
69
|
+
- With no `--once`, it loops, polling every `SEVDESK_POLL_INTERVAL_SECONDS`
|
|
70
|
+
(default 300) until the process is stopped.
|
|
71
|
+
- `--dry-run` walks the full pipeline (real API calls, real verdicts) but writes
|
|
72
|
+
no files and persists no state. Good for a first, safe look.
|
|
73
|
+
|
|
74
|
+
A cron entry that runs it every 15 minutes:
|
|
75
|
+
|
|
76
|
+
```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
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
Every setting is read from the environment; the flags below override the matching
|
|
83
|
+
variable.
|
|
84
|
+
|
|
85
|
+
| Variable | Flag | Default | Description |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `SEVDESK_API_TOKEN` | `--sevdesk-token` | (required) | sevDesk API token. |
|
|
88
|
+
| `BELIQ_API_KEY` | `--api-key` | (required) | beliq API key. |
|
|
89
|
+
| `SEVDESK_TARGET_FORMATS` | `--target-format` | (none) | Comma-separated convert targets. Empty = validation-only. |
|
|
90
|
+
| `SEVDESK_TARGET_PROFILE` | | (none) | Factur-X / ZUGFeRD profile, for a facturx / zugferd target. |
|
|
91
|
+
| `SEVDESK_INVOICE_STATUS` | `--status` | `Open` | `Draft`, `Open`, `Paid`, or a numeric code. |
|
|
92
|
+
| `SEVDESK_POLL_WINDOW_DAYS` | `--poll-window-days` | `30` | Only fetch invoices dated within n days back; `0` disables. |
|
|
93
|
+
| `SEVDESK_STATE_FILE` | `--state` | `.beliq-sevdesk-state.json` | The persisted high-water-mark. |
|
|
94
|
+
| `SEVDESK_OUTPUT_DIR` | `--output` | `./out` | Where converted documents are written. |
|
|
95
|
+
| `SEVDESK_POLL_INTERVAL_SECONDS` | `--interval` | `300` | Seconds between polls in daemon mode. |
|
|
96
|
+
| `SEVDESK_PAGE_SIZE` | | `100` | Page size for the invoice listing. |
|
|
97
|
+
| `SEVDESK_MAX_RETRIES` | | `4` | Retries on a sevDesk 429 / 5xx / network error. |
|
|
98
|
+
| `SEVDESK_BASE_URL` | | `https://api.sevdesk.de/api/v1` | Override for a mock or a future version. |
|
|
99
|
+
| `BELIQ_BASE_URL` | | `https://api.beliq.eu` | Override for a self-hosted beliq. |
|
|
100
|
+
| `BELIQ_AUTH` | | `header` | How the beliq key is sent: `header` (X-API-Key) or `bearer`. |
|
|
101
|
+
|
|
102
|
+
Allowed target formats: `cii`, `ubl`, `zugferd`, `facturx`, `xrechnung`,
|
|
103
|
+
`peppol-bis`. Allowed profiles: `basicwl`, `en16931`, `extended`,
|
|
104
|
+
`extended-ctc-fr`.
|
|
105
|
+
|
|
106
|
+
## Exit codes
|
|
107
|
+
|
|
108
|
+
Meaningful with `--once`, so cron and CI can act on the result:
|
|
109
|
+
|
|
110
|
+
| Code | Meaning |
|
|
111
|
+
|---|---|
|
|
112
|
+
| 0 | every processed invoice was valid, or there was nothing to do |
|
|
113
|
+
| 1 | at least one invoice failed validation |
|
|
114
|
+
| 2 | config / usage error (missing token or key, bad flag or value) |
|
|
115
|
+
| 3 | a sevDesk or beliq API error, or an invoice that errored mid-pipeline |
|
|
116
|
+
| 4 | I/O error (unreadable state file, unwritable output) |
|
|
117
|
+
|
|
118
|
+
An error (code 3) outranks an invalid document (code 1): not getting a verdict is
|
|
119
|
+
worse than getting a bad one.
|
|
120
|
+
|
|
121
|
+
## Logging
|
|
122
|
+
|
|
123
|
+
Structured events are written to stderr, one JSON object per line, for log
|
|
124
|
+
aggregation. The final human-readable summary is written to stdout, so
|
|
125
|
+
`beliq-sevdesk --once | tail -1` gives you the verdict while logs stay separate.
|
|
126
|
+
|
|
127
|
+
## A note on the sevDesk token
|
|
128
|
+
|
|
129
|
+
The sevDesk API token is account-wide, unscoped, and does not expire. This worker
|
|
130
|
+
is built so that token stays on your side: it never sends the token anywhere but
|
|
131
|
+
sevDesk, and it is not a hosted service holding your credentials. Store it the way
|
|
132
|
+
you store any production secret.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npm install
|
|
138
|
+
npm run build
|
|
139
|
+
npm test # unit tests, no network
|
|
140
|
+
npm run scrub:check # no em-dash
|
|
141
|
+
|
|
142
|
+
# live smoke against the real sevDesk + beliq APIs (skipped without creds):
|
|
143
|
+
SEVDESK_API_TOKEN=... BELIQ_API_KEY=... npm run test:integration
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ConfigError } from './errors.js';
|
|
2
|
+
/** Flags that take no value. */
|
|
3
|
+
const BOOLEAN_FLAGS = new Set(['once', 'dry-run']);
|
|
4
|
+
/**
|
|
5
|
+
* Flags that take a value and override the matching environment variable. The
|
|
6
|
+
* worker is env-first (it runs as a container / cron job); these are the handful
|
|
7
|
+
* worth tweaking per invocation. Everything else stays env-only (see config.ts).
|
|
8
|
+
*/
|
|
9
|
+
const VALUE_FLAGS = new Set([
|
|
10
|
+
'state',
|
|
11
|
+
'output',
|
|
12
|
+
'target-format',
|
|
13
|
+
'status',
|
|
14
|
+
'poll-window-days',
|
|
15
|
+
'interval',
|
|
16
|
+
'api-key',
|
|
17
|
+
'sevdesk-token',
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Parse argv into flags. Hand-rolled (no dependency): the surface is small and
|
|
21
|
+
* this stays pure and unit-testable. Unknown flags and any positional argument
|
|
22
|
+
* are usage errors.
|
|
23
|
+
*/
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const out = { flags: {}, help: false, version: false };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const token = argv[i];
|
|
28
|
+
if (token === '--help' || token === '-h') {
|
|
29
|
+
out.help = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (token === '--version' || token === '-v') {
|
|
33
|
+
out.version = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!token.startsWith('--')) {
|
|
37
|
+
throw new ConfigError(`unexpected argument "${token}". Run beliq-sevdesk --help.`);
|
|
38
|
+
}
|
|
39
|
+
const body = token.slice(2);
|
|
40
|
+
const eq = body.indexOf('=');
|
|
41
|
+
const name = eq >= 0 ? body.slice(0, eq) : body;
|
|
42
|
+
const inlineValue = eq >= 0 ? body.slice(eq + 1) : undefined;
|
|
43
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
44
|
+
if (inlineValue !== undefined)
|
|
45
|
+
throw new ConfigError(`option --${name} takes no value`);
|
|
46
|
+
out.flags[name] = true;
|
|
47
|
+
}
|
|
48
|
+
else if (VALUE_FLAGS.has(name)) {
|
|
49
|
+
let value = inlineValue;
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
value = argv[++i];
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
throw new ConfigError(`option --${name} needs a value`);
|
|
54
|
+
}
|
|
55
|
+
out.flags[name] = value;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
throw new ConfigError(`unknown option --${name}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
export function flagBool(args, name) {
|
|
64
|
+
return args.flags[name] === true;
|
|
65
|
+
}
|
|
66
|
+
export function flagStr(args, name) {
|
|
67
|
+
const value = args.flags[name];
|
|
68
|
+
return typeof value === 'string' ? value : undefined;
|
|
69
|
+
}
|
package/dist/beliq.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { BeliqApiError } from '@beliq/sdk';
|
|
2
|
+
import { parseArgs } from './args.js';
|
|
3
|
+
import { resolveConfig } from './config.js';
|
|
4
|
+
import { makeBeliqClient } from './beliq.js';
|
|
5
|
+
import { SevDeskClient } from './sevdesk.js';
|
|
6
|
+
import { runWorker } from './worker.js';
|
|
7
|
+
import { EXIT } from './exit.js';
|
|
8
|
+
import { ConfigError, IoError, SevDeskApiError } from './errors.js';
|
|
9
|
+
import { HELP, version } from './help.js';
|
|
10
|
+
/**
|
|
11
|
+
* The entry: parse argv, handle --help / --version, resolve config, build the
|
|
12
|
+
* real sevDesk + beliq clients, run the worker, and map every error class to its
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export async function main(argv, log, env = process.env) {
|
|
17
|
+
let config;
|
|
18
|
+
try {
|
|
19
|
+
const args = parseArgs(argv);
|
|
20
|
+
if (args.help) {
|
|
21
|
+
log.summary(HELP);
|
|
22
|
+
return EXIT.OK;
|
|
23
|
+
}
|
|
24
|
+
if (args.version) {
|
|
25
|
+
log.summary(version());
|
|
26
|
+
return EXIT.OK;
|
|
27
|
+
}
|
|
28
|
+
config = resolveConfig(args, env);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err instanceof ConfigError) {
|
|
32
|
+
log.error('usage', { message: err.message });
|
|
33
|
+
return EXIT.USAGE;
|
|
34
|
+
}
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const sevdesk = new SevDeskClient({
|
|
39
|
+
token: config.sevdeskToken,
|
|
40
|
+
baseUrl: config.sevdeskBaseUrl,
|
|
41
|
+
maxRetries: config.maxRetries,
|
|
42
|
+
});
|
|
43
|
+
const beliq = makeBeliqClient(config);
|
|
44
|
+
return await runWorker(config, { sevdesk, beliq, log });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err instanceof ConfigError) {
|
|
48
|
+
log.error('usage', { message: err.message });
|
|
49
|
+
return EXIT.USAGE;
|
|
50
|
+
}
|
|
51
|
+
if (err instanceof IoError) {
|
|
52
|
+
log.error('io', { message: err.message });
|
|
53
|
+
return EXIT.IO;
|
|
54
|
+
}
|
|
55
|
+
if (err instanceof SevDeskApiError) {
|
|
56
|
+
log.error('sevdesk', { status: err.status, message: err.message });
|
|
57
|
+
return EXIT.API;
|
|
58
|
+
}
|
|
59
|
+
if (err instanceof BeliqApiError) {
|
|
60
|
+
log.error('beliq', { status: err.status, code: err.code, message: err.message });
|
|
61
|
+
return EXIT.API;
|
|
62
|
+
}
|
|
63
|
+
log.error('unexpected', { message: err.message });
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { DEFAULT_BASE_URL, LIVE_CONVERT_TARGET_FORMATS, LIVE_PROFILES, } from '@beliq/sdk';
|
|
2
|
+
import { flagBool, flagStr } from './args.js';
|
|
3
|
+
import { ConfigError } from './errors.js';
|
|
4
|
+
/** sevDesk default REST base. Endpoints hang off /Invoice, /Invoice/{id}/getXml. */
|
|
5
|
+
export const DEFAULT_SEVDESK_BASE_URL = 'https://api.sevdesk.de/api/v1';
|
|
6
|
+
/** sevDesk invoice status codes (Settings dropdown values), keyed by friendly name. */
|
|
7
|
+
const STATUS_CODES = { draft: '100', open: '200', paid: '1000' };
|
|
8
|
+
/** Read a non-empty trimmed value; flag wins over env. */
|
|
9
|
+
function pick(args, flag, env, key) {
|
|
10
|
+
const v = (flagStr(args, flag) ?? env[key])?.trim();
|
|
11
|
+
return v ? v : undefined;
|
|
12
|
+
}
|
|
13
|
+
function parseIntEnv(raw, fallback, label) {
|
|
14
|
+
if (raw === undefined)
|
|
15
|
+
return fallback;
|
|
16
|
+
const n = Number(raw);
|
|
17
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
|
18
|
+
throw new ConfigError(`${label} must be a non-negative integer, got "${raw}"`);
|
|
19
|
+
}
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
function resolveStatus(raw) {
|
|
23
|
+
if (!raw)
|
|
24
|
+
return STATUS_CODES.open;
|
|
25
|
+
const lower = raw.toLowerCase();
|
|
26
|
+
if (lower in STATUS_CODES)
|
|
27
|
+
return STATUS_CODES[lower];
|
|
28
|
+
if (/^\d+$/.test(raw))
|
|
29
|
+
return raw;
|
|
30
|
+
throw new ConfigError(`invalid status "${raw}". Use Draft, Open, Paid, or a numeric sevDesk status code.`);
|
|
31
|
+
}
|
|
32
|
+
function resolveTargets(raw) {
|
|
33
|
+
if (!raw)
|
|
34
|
+
return [];
|
|
35
|
+
const parts = raw
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((p) => p.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
for (const p of parts) {
|
|
40
|
+
if (!LIVE_CONVERT_TARGET_FORMATS.includes(p)) {
|
|
41
|
+
throw new ConfigError(`invalid target format "${p}". Allowed: ${LIVE_CONVERT_TARGET_FORMATS.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
function resolveProfile(raw) {
|
|
47
|
+
if (!raw)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (!LIVE_PROFILES.includes(raw)) {
|
|
50
|
+
throw new ConfigError(`invalid target profile "${raw}". Allowed: ${LIVE_PROFILES.join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the typed worker config with precedence flag > env > default. Throws a
|
|
56
|
+
* ConfigError (mapped to EXIT.USAGE) for a missing credential or a bad value, so
|
|
57
|
+
* the worker reports a clean usage error rather than failing mid-poll. The SDK
|
|
58
|
+
* reads no environment itself, so the worker owns BELIQ_* too.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveConfig(args, env = process.env) {
|
|
61
|
+
const sevdeskToken = pick(args, 'sevdesk-token', env, 'SEVDESK_API_TOKEN');
|
|
62
|
+
if (!sevdeskToken) {
|
|
63
|
+
throw new ConfigError('no sevDesk token. Set SEVDESK_API_TOKEN or pass --sevdesk-token (Settings -> Advanced -> API).');
|
|
64
|
+
}
|
|
65
|
+
const beliqApiKey = pick(args, 'api-key', env, 'BELIQ_API_KEY');
|
|
66
|
+
if (!beliqApiKey) {
|
|
67
|
+
throw new ConfigError('no beliq API key. Set BELIQ_API_KEY or pass --api-key. Create a key in the beliq dashboard under API Keys.');
|
|
68
|
+
}
|
|
69
|
+
const beliqAuthRaw = (env.BELIQ_AUTH ?? '').trim().toLowerCase();
|
|
70
|
+
const beliqAuth = beliqAuthRaw === 'bearer' ? 'bearer' : 'header';
|
|
71
|
+
return {
|
|
72
|
+
sevdeskToken,
|
|
73
|
+
sevdeskBaseUrl: (env.SEVDESK_BASE_URL?.trim() || DEFAULT_SEVDESK_BASE_URL).replace(/\/+$/, ''),
|
|
74
|
+
beliqApiKey,
|
|
75
|
+
beliqBaseUrl: (env.BELIQ_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
76
|
+
beliqAuth,
|
|
77
|
+
targetFormats: resolveTargets(flagStr(args, 'target-format') ?? env.SEVDESK_TARGET_FORMATS),
|
|
78
|
+
targetProfile: resolveProfile(env.SEVDESK_TARGET_PROFILE?.trim()),
|
|
79
|
+
status: resolveStatus(pick(args, 'status', env, 'SEVDESK_INVOICE_STATUS')),
|
|
80
|
+
pollWindowDays: parseIntEnv(flagStr(args, 'poll-window-days') ?? env.SEVDESK_POLL_WINDOW_DAYS, 30, 'poll window (days)'),
|
|
81
|
+
stateFile: pick(args, 'state', env, 'SEVDESK_STATE_FILE') ?? '.beliq-sevdesk-state.json',
|
|
82
|
+
outputDir: pick(args, 'output', env, 'SEVDESK_OUTPUT_DIR') ?? './out',
|
|
83
|
+
intervalSeconds: parseIntEnv(flagStr(args, 'interval') ?? env.SEVDESK_POLL_INTERVAL_SECONDS, 300, 'poll interval (seconds)'),
|
|
84
|
+
pageSize: parseIntEnv(env.SEVDESK_PAGE_SIZE, 100, 'page size') || 100,
|
|
85
|
+
maxRetries: parseIntEnv(env.SEVDESK_MAX_RETRIES, 4, 'max retries'),
|
|
86
|
+
once: flagBool(args, 'once'),
|
|
87
|
+
dryRun: flagBool(args, 'dry-run'),
|
|
88
|
+
};
|
|
89
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A user-facing config or usage problem (missing token/key, bad flag, bad value). Maps to EXIT.USAGE. */
|
|
2
|
+
export class ConfigError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** A failure reading the state file, creating the output dir, or writing a document. Maps to EXIT.IO. */
|
|
5
|
+
export class IoError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** A non-2xx sevDesk response (after retries) or a response body we could not parse. Maps to EXIT.API. */
|
|
8
|
+
export class SevDeskApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'SevDeskApiError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/exit.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const EXIT = {
|
|
2
|
+
/** Success: every processed invoice was valid, or there was nothing to do. */
|
|
3
|
+
OK: 0,
|
|
4
|
+
/** At least one invoice failed validation (the CI/Action "document failed" contract). */
|
|
5
|
+
INVALID: 1,
|
|
6
|
+
/** A config or usage problem: missing token/key, bad flag, bad value. */
|
|
7
|
+
USAGE: 2,
|
|
8
|
+
/** A sevDesk or beliq API error (bad key, quota, engine), or an invoice that errored mid-pipeline. */
|
|
9
|
+
API: 3,
|
|
10
|
+
/** A local I/O error: unreadable state file, or an output dir/file that could not be written. */
|
|
11
|
+
IO: 4,
|
|
12
|
+
};
|
|
13
|
+
export function emptyCounts() {
|
|
14
|
+
return { valid: 0, invalid: 0, error: 0 };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The run's exit code from the tallied outcomes. An error (we never got a
|
|
18
|
+
* verdict) is worse than an invalid document (a verdict of non-compliant), so it
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
export function summaryExitCode(counts) {
|
|
23
|
+
if (counts.error > 0)
|
|
24
|
+
return EXIT.API;
|
|
25
|
+
if (counts.invalid > 0)
|
|
26
|
+
return EXIT.INVALID;
|
|
27
|
+
return EXIT.OK;
|
|
28
|
+
}
|
package/dist/help.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
export function version() {
|
|
4
|
+
const pkg = require('../package.json');
|
|
5
|
+
return pkg.version;
|
|
6
|
+
}
|
|
7
|
+
export const HELP = `beliq-sevdesk: poll sevDesk invoices, convert them, and validate them with beliq
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
beliq-sevdesk [--once] [--dry-run] [options]
|
|
11
|
+
|
|
12
|
+
Runs a poll loop by default. With --once it polls a single time and exits with a
|
|
13
|
+
code you can gate CI or cron on (see below). Configuration is read from the
|
|
14
|
+
environment; the flags below override the matching variable.
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--once poll once and exit (default: loop as a daemon)
|
|
18
|
+
--dry-run walk the full pipeline but write no files and persist no state
|
|
19
|
+
--target-format <csv> beliq convert targets, comma-separated (default: SEVDESK_TARGET_FORMATS)
|
|
20
|
+
--status <s> Draft | Open | Paid, or a numeric code (default: SEVDESK_INVOICE_STATUS, else Open)
|
|
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)
|
|
23
|
+
--output <dir> where converted documents are written (default: SEVDESK_OUTPUT_DIR, else ./out)
|
|
24
|
+
--interval <seconds> seconds between polls in daemon mode (default: 300)
|
|
25
|
+
--sevdesk-token <token> sevDesk API token (default: SEVDESK_API_TOKEN)
|
|
26
|
+
--api-key <key> beliq API key (default: BELIQ_API_KEY)
|
|
27
|
+
-h, --help show this help
|
|
28
|
+
-v, --version show the version
|
|
29
|
+
|
|
30
|
+
Exit codes (meaningful with --once):
|
|
31
|
+
0 every processed invoice was valid, or there was nothing to do
|
|
32
|
+
1 at least one invoice failed validation
|
|
33
|
+
2 config/usage error (missing token/key, bad flag or value)
|
|
34
|
+
3 a sevDesk or beliq API error, or an invoice that errored mid-pipeline
|
|
35
|
+
4 I/O error (unreadable state file, unwritable output)
|
|
36
|
+
|
|
37
|
+
Set SEVDESK_API_TOKEN (Settings -> Advanced -> API) and BELIQ_API_KEY (beliq
|
|
38
|
+
dashboard -> API Keys). The sevDesk token never leaves this environment.
|
|
39
|
+
`;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from './cli.js';
|
|
3
|
+
import { nodeLogger } from './log.js';
|
|
4
|
+
main(process.argv.slice(2), nodeLogger()).then((code) => {
|
|
5
|
+
process.exitCode = code;
|
|
6
|
+
}, (err) => {
|
|
7
|
+
process.stderr.write(`beliq-sevdesk: fatal: ${err.message}\n`);
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
});
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function nodeLogger() {
|
|
2
|
+
const emit = (level, event, fields) => {
|
|
3
|
+
process.stderr.write(`${JSON.stringify({ level, event, ...fields })}\n`);
|
|
4
|
+
};
|
|
5
|
+
return {
|
|
6
|
+
info: (event, fields) => emit('info', event, fields),
|
|
7
|
+
error: (event, fields) => emit('error', event, fields),
|
|
8
|
+
summary: (line) => process.stdout.write(`${line}\n`),
|
|
9
|
+
};
|
|
10
|
+
}
|
package/dist/sevdesk.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { SevDeskApiError } from './errors.js';
|
|
2
|
+
/** A hard ceiling on pages walked per poll, so a misconfigured window can't loop forever. */
|
|
3
|
+
const MAX_PAGES = 100;
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const decoder = new TextDecoder('utf-8');
|
|
6
|
+
function looksLikeXml(s) {
|
|
7
|
+
return s.trimStart().startsWith('<');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Pull the XML out of a getXml response. sevDesk's exact envelope for this
|
|
11
|
+
* endpoint is not publicly pinned (the live round-trip that confirms it is
|
|
12
|
+
* operator-gated), so this handles the documented-plausible shapes in one place:
|
|
13
|
+
* a raw XML body, `{ objects: "<xml>" }`, or `{ objects: { content, base64 } }`,
|
|
14
|
+
* with the payload optionally base64-encoded. If a live response differs, this
|
|
15
|
+
* is the single function to adjust.
|
|
16
|
+
*/
|
|
17
|
+
export function extractXml(contentType, text) {
|
|
18
|
+
if (contentType.includes('xml') || looksLikeXml(text))
|
|
19
|
+
return encoder.encode(text);
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new SevDeskApiError('getXml: response was neither XML nor JSON', 200);
|
|
26
|
+
}
|
|
27
|
+
const envelope = parsed?.objects ?? parsed;
|
|
28
|
+
let payload;
|
|
29
|
+
let base64Flag = false;
|
|
30
|
+
if (typeof envelope === 'string') {
|
|
31
|
+
payload = envelope;
|
|
32
|
+
}
|
|
33
|
+
else if (envelope && typeof envelope === 'object') {
|
|
34
|
+
const o = envelope;
|
|
35
|
+
payload = o.content ?? o.xml ?? o.file;
|
|
36
|
+
base64Flag = o.base64 === true;
|
|
37
|
+
}
|
|
38
|
+
if (typeof payload !== 'string') {
|
|
39
|
+
throw new SevDeskApiError('getXml: could not locate the XML in the response', 200);
|
|
40
|
+
}
|
|
41
|
+
if (base64Flag)
|
|
42
|
+
return new Uint8Array(Buffer.from(payload, 'base64'));
|
|
43
|
+
if (looksLikeXml(payload))
|
|
44
|
+
return encoder.encode(payload);
|
|
45
|
+
// No explicit flag and it does not look like XML: try base64, accept only if it decodes to XML.
|
|
46
|
+
try {
|
|
47
|
+
const decoded = Buffer.from(payload, 'base64');
|
|
48
|
+
if (looksLikeXml(decoder.decode(decoded)))
|
|
49
|
+
return new Uint8Array(decoded);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// fall through to treating the payload as literal text
|
|
53
|
+
}
|
|
54
|
+
return encoder.encode(payload);
|
|
55
|
+
}
|
|
56
|
+
function isRetryable(status) {
|
|
57
|
+
return status === 429 || status >= 500;
|
|
58
|
+
}
|
|
59
|
+
/** Thin sevDesk REST client over an injectable fetch, with pagination and retry/backoff. */
|
|
60
|
+
export class SevDeskClient {
|
|
61
|
+
#token;
|
|
62
|
+
#baseUrl;
|
|
63
|
+
#fetch;
|
|
64
|
+
#sleep;
|
|
65
|
+
#maxRetries;
|
|
66
|
+
#baseRetryDelayMs;
|
|
67
|
+
constructor(options) {
|
|
68
|
+
if (!options.token)
|
|
69
|
+
throw new Error('sevdesk: token is required');
|
|
70
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
71
|
+
if (typeof fetchImpl !== 'function') {
|
|
72
|
+
throw new Error('sevdesk: no global fetch available; pass options.fetch');
|
|
73
|
+
}
|
|
74
|
+
this.#token = options.token;
|
|
75
|
+
this.#baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
76
|
+
this.#fetch = fetchImpl;
|
|
77
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
78
|
+
this.#maxRetries = options.maxRetries ?? 4;
|
|
79
|
+
this.#baseRetryDelayMs = options.baseRetryDelayMs ?? 500;
|
|
80
|
+
}
|
|
81
|
+
async listInvoices(params) {
|
|
82
|
+
const limit = params.pageSize && params.pageSize > 0 ? params.pageSize : 100;
|
|
83
|
+
const all = [];
|
|
84
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
85
|
+
const query = { limit, offset: page * limit };
|
|
86
|
+
if (params.status)
|
|
87
|
+
query.status = params.status;
|
|
88
|
+
if (params.startDate !== undefined)
|
|
89
|
+
query.startDate = params.startDate;
|
|
90
|
+
const { text } = await this.#request('GET', '/Invoice', query);
|
|
91
|
+
const objects = this.#parseObjects(text);
|
|
92
|
+
for (const o of objects)
|
|
93
|
+
all.push(normalizeInvoice(o));
|
|
94
|
+
if (objects.length < limit)
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
return all;
|
|
98
|
+
}
|
|
99
|
+
async getInvoiceXml(id) {
|
|
100
|
+
const { text, contentType } = await this.#request('GET', `/Invoice/${encodeURIComponent(id)}/getXml`);
|
|
101
|
+
return extractXml(contentType, text);
|
|
102
|
+
}
|
|
103
|
+
#parseObjects(text) {
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(text);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new SevDeskApiError('sevDesk list response was not JSON', 200);
|
|
110
|
+
}
|
|
111
|
+
const objects = parsed?.objects;
|
|
112
|
+
if (!Array.isArray(objects)) {
|
|
113
|
+
throw new SevDeskApiError('sevDesk list response had no "objects" array', 200);
|
|
114
|
+
}
|
|
115
|
+
return objects;
|
|
116
|
+
}
|
|
117
|
+
async #request(method, path, query) {
|
|
118
|
+
const url = new URL(this.#baseUrl + path);
|
|
119
|
+
if (query) {
|
|
120
|
+
for (const [k, v] of Object.entries(query))
|
|
121
|
+
url.searchParams.set(k, String(v));
|
|
122
|
+
}
|
|
123
|
+
let lastError;
|
|
124
|
+
for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
res = await this.#fetch(url.toString(), {
|
|
128
|
+
method,
|
|
129
|
+
headers: { Authorization: this.#token, Accept: 'application/json' },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
lastError = err;
|
|
134
|
+
if (attempt < this.#maxRetries) {
|
|
135
|
+
await this.#backoff(attempt);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
throw new SevDeskApiError(`sevDesk ${method} ${path} network error: ${lastError.message}`, 0);
|
|
139
|
+
}
|
|
140
|
+
if (res.ok) {
|
|
141
|
+
return {
|
|
142
|
+
status: res.status,
|
|
143
|
+
text: await res.text(),
|
|
144
|
+
contentType: res.headers.get('content-type') ?? '',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (isRetryable(res.status) && attempt < this.#maxRetries) {
|
|
148
|
+
await this.#backoff(attempt);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
throw new SevDeskApiError(`sevDesk ${method} ${path} failed with status ${res.status}`, res.status);
|
|
152
|
+
}
|
|
153
|
+
// Unreachable: the loop either returns or throws. Satisfies noImplicitReturns.
|
|
154
|
+
throw new SevDeskApiError(`sevDesk ${method} ${path} exhausted retries`, 0);
|
|
155
|
+
}
|
|
156
|
+
#backoff(attempt) {
|
|
157
|
+
return this.#sleep(this.#baseRetryDelayMs * 2 ** attempt);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function normalizeInvoice(raw) {
|
|
161
|
+
return {
|
|
162
|
+
...raw,
|
|
163
|
+
id: String(raw.id),
|
|
164
|
+
invoiceNumber: raw.invoiceNumber != null ? String(raw.invoiceNumber) : undefined,
|
|
165
|
+
status: raw.status != null ? String(raw.status) : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { IoError } from './errors.js';
|
|
3
|
+
const EMPTY = { lastInvoiceId: 0 };
|
|
4
|
+
/**
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadState(path) {
|
|
10
|
+
let text;
|
|
11
|
+
try {
|
|
12
|
+
text = await readFile(path, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err.code === 'ENOENT')
|
|
16
|
+
return { ...EMPTY };
|
|
17
|
+
throw new IoError(`could not read state file ${path}: ${err.message}`);
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(text);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new IoError(`state file ${path} is not valid JSON; fix or delete it to start fresh`);
|
|
25
|
+
}
|
|
26
|
+
const lastInvoiceId = parsed?.lastInvoiceId;
|
|
27
|
+
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`);
|
|
29
|
+
}
|
|
30
|
+
const lastPolledAt = parsed.lastPolledAt;
|
|
31
|
+
return { lastInvoiceId, lastPolledAt: typeof lastPolledAt === 'string' ? lastPolledAt : undefined };
|
|
32
|
+
}
|
|
33
|
+
export async function saveState(path, state) {
|
|
34
|
+
try {
|
|
35
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new IoError(`could not write state file ${path}: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { IoError } from './errors.js';
|
|
4
|
+
import { emptyCounts, summaryExitCode } from './exit.js';
|
|
5
|
+
import { loadState, saveState } from './state.js';
|
|
6
|
+
/** Convert targets that produce a hybrid PDF rather than a standalone XML document. */
|
|
7
|
+
const PDF_TARGETS = new Set(['facturx', 'zugferd']);
|
|
8
|
+
/** Seconds in a day, for the poll-window date filter. */
|
|
9
|
+
const SECONDS_PER_DAY = 86_400;
|
|
10
|
+
function safeName(inv) {
|
|
11
|
+
const base = inv.invoiceNumber || inv.id;
|
|
12
|
+
return base.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
13
|
+
}
|
|
14
|
+
async function ensureDir(dir) {
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(dir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
throw new IoError(`could not create output dir ${dir}: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function writeOutput(dir, name, bytes) {
|
|
23
|
+
const path = join(dir, name);
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(path, bytes);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
throw new IoError(`could not write ${path}: ${err.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run one invoice through the pipeline: pull its XML, convert it to each
|
|
33
|
+
* configured target (writing the bytes out), then validate the source document
|
|
34
|
+
* for an independent authority-pinned verdict sevDesk does not provide. Returns
|
|
35
|
+
* the classification; a throw here is caught by the caller and counted as an
|
|
36
|
+
* error (no verdict), leaving the high-water-mark short of this invoice so it is
|
|
37
|
+
* retried next poll.
|
|
38
|
+
*/
|
|
39
|
+
async function processInvoice(inv, config, deps) {
|
|
40
|
+
const xml = await deps.sevdesk.getInvoiceXml(inv.id);
|
|
41
|
+
for (const target of config.targetFormats) {
|
|
42
|
+
const result = await deps.beliq.convert(xml, {
|
|
43
|
+
targetFormat: target,
|
|
44
|
+
targetProfile: PDF_TARGETS.has(target) ? config.targetProfile : undefined,
|
|
45
|
+
});
|
|
46
|
+
const ext = PDF_TARGETS.has(target) ? 'pdf' : 'xml';
|
|
47
|
+
const file = `${safeName(inv)}-${target}.${ext}`;
|
|
48
|
+
const lostElements = result.meta.lostElementsCount ?? 0;
|
|
49
|
+
if (config.dryRun) {
|
|
50
|
+
deps.log.info('convert.dryRun', { id: inv.id, target, file, lostElements });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
await writeOutput(config.outputDir, file, result.bytes);
|
|
54
|
+
deps.log.info('convert', { id: inv.id, target, file, lostElements });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const verdict = await deps.beliq.validate(xml, {});
|
|
58
|
+
const classification = verdict.valid ? 'valid' : 'invalid';
|
|
59
|
+
deps.log.info('validate', {
|
|
60
|
+
id: inv.id,
|
|
61
|
+
number: inv.invoiceNumber,
|
|
62
|
+
valid: verdict.valid,
|
|
63
|
+
errors: verdict.errors?.length ?? 0,
|
|
64
|
+
warnings: verdict.warnings?.length ?? 0,
|
|
65
|
+
classification,
|
|
66
|
+
});
|
|
67
|
+
return classification;
|
|
68
|
+
}
|
|
69
|
+
function formatSummary(counts, fresh, dryRun) {
|
|
70
|
+
const prefix = dryRun ? '[dry-run] ' : '';
|
|
71
|
+
return `${prefix}processed ${fresh} invoice(s): ${counts.valid} valid, ${counts.invalid} invalid, ${counts.error} error`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Poll sevDesk once: fetch invoices in the configured status/window, process
|
|
75
|
+
* only those newer than the high-water-mark (in ascending id order so the mark
|
|
76
|
+
* advances monotonically and dedupes by id), and persist the advanced mark. The
|
|
77
|
+
* mark advances only across the contiguous error-free prefix: an invoice that
|
|
78
|
+
* errors (and every invoice after it in this batch) is left for the next poll,
|
|
79
|
+
* which is safe because reprocessing is idempotent.
|
|
80
|
+
*/
|
|
81
|
+
export async function pollOnce(config, deps) {
|
|
82
|
+
const now = deps.now ?? (() => Date.now());
|
|
83
|
+
if (config.targetFormats.length > 0 && !config.dryRun) {
|
|
84
|
+
await ensureDir(config.outputDir);
|
|
85
|
+
}
|
|
86
|
+
const state = await loadState(config.stateFile);
|
|
87
|
+
const startDate = config.pollWindowDays > 0 ? Math.floor(now() / 1000) - config.pollWindowDays * SECONDS_PER_DAY : undefined;
|
|
88
|
+
const invoices = await deps.sevdesk.listInvoices({
|
|
89
|
+
status: config.status,
|
|
90
|
+
startDate,
|
|
91
|
+
pageSize: config.pageSize,
|
|
92
|
+
});
|
|
93
|
+
const fresh = invoices
|
|
94
|
+
.map((inv) => ({ inv, idNum: Number(inv.id) }))
|
|
95
|
+
.filter(({ idNum }) => Number.isFinite(idNum) && idNum > state.lastInvoiceId)
|
|
96
|
+
.sort((a, b) => a.idNum - b.idNum);
|
|
97
|
+
deps.log.info('poll', {
|
|
98
|
+
status: config.status,
|
|
99
|
+
since: state.lastInvoiceId,
|
|
100
|
+
listed: invoices.length,
|
|
101
|
+
fresh: fresh.length,
|
|
102
|
+
});
|
|
103
|
+
const counts = emptyCounts();
|
|
104
|
+
let highWater = state.lastInvoiceId;
|
|
105
|
+
let blocked = false;
|
|
106
|
+
for (const { inv, idNum } of fresh) {
|
|
107
|
+
let classification;
|
|
108
|
+
try {
|
|
109
|
+
classification = await processInvoice(inv, config, deps);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
classification = 'error';
|
|
113
|
+
deps.log.error('invoice.error', {
|
|
114
|
+
id: inv.id,
|
|
115
|
+
number: inv.invoiceNumber,
|
|
116
|
+
message: err.message,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
counts[classification]++;
|
|
120
|
+
if (classification === 'error')
|
|
121
|
+
blocked = true;
|
|
122
|
+
else if (!blocked)
|
|
123
|
+
highWater = idNum;
|
|
124
|
+
}
|
|
125
|
+
if (!config.dryRun && highWater > state.lastInvoiceId) {
|
|
126
|
+
await saveState(config.stateFile, {
|
|
127
|
+
lastInvoiceId: highWater,
|
|
128
|
+
lastPolledAt: new Date(now()).toISOString(),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
deps.log.summary(formatSummary(counts, fresh.length, config.dryRun));
|
|
132
|
+
return { counts, processedTo: highWater };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Run the worker. With --once, poll a single time and return the exit code (the
|
|
136
|
+
* CI/cron contract). Otherwise loop forever, polling every interval, until the
|
|
137
|
+
* process is signalled.
|
|
138
|
+
*/
|
|
139
|
+
export async function runWorker(config, deps) {
|
|
140
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
141
|
+
if (config.once) {
|
|
142
|
+
const { counts } = await pollOnce(config, deps);
|
|
143
|
+
return summaryExitCode(counts);
|
|
144
|
+
}
|
|
145
|
+
for (;;) {
|
|
146
|
+
await pollOnce(config, deps);
|
|
147
|
+
await sleep(config.intervalSeconds * 1000);
|
|
148
|
+
}
|
|
149
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "beliq-sevdesk",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "beliq",
|
|
9
|
+
"email": "hello@beliq.eu"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://beliq.eu",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/beliq-eu/beliq-sevdesk.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/beliq-eu/beliq-sevdesk/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"beliq",
|
|
21
|
+
"sevdesk",
|
|
22
|
+
"e-invoice",
|
|
23
|
+
"einvoice",
|
|
24
|
+
"xrechnung",
|
|
25
|
+
"zugferd",
|
|
26
|
+
"factur-x",
|
|
27
|
+
"peppol",
|
|
28
|
+
"en16931",
|
|
29
|
+
"validation",
|
|
30
|
+
"conversion",
|
|
31
|
+
"worker",
|
|
32
|
+
"cron"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.15"
|
|
36
|
+
},
|
|
37
|
+
"bin": {
|
|
38
|
+
"beliq-sevdesk": "dist/index.js"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"provenance": true
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"clean": "rm -rf dist",
|
|
48
|
+
"build": "npm run clean && tsc",
|
|
49
|
+
"dev": "tsc --watch",
|
|
50
|
+
"start": "node dist/index.js",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"lint": "eslint src",
|
|
53
|
+
"lint:fix": "eslint src --fix",
|
|
54
|
+
"test": "vitest run --exclude test/integration.test.ts",
|
|
55
|
+
"test:integration": "vitest run test/integration.test.ts",
|
|
56
|
+
"scrub:check": "bash scripts/scrub-check.sh",
|
|
57
|
+
"prepublishOnly": "npm run build"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@beliq/sdk": "^0.1.1"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/node": "^20.14.0",
|
|
64
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
65
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
66
|
+
"eslint": "^8.57.0",
|
|
67
|
+
"typescript": "^5.5.4",
|
|
68
|
+
"vitest": "^2.1.8"
|
|
69
|
+
}
|
|
70
|
+
}
|