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 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
@@ -0,0 +1,4 @@
1
+ import { Beliq } from '@beliq/sdk';
2
+ export function makeBeliqClient(config) {
3
+ return new Beliq({ apiKey: config.beliqApiKey, baseUrl: config.beliqBaseUrl, auth: config.beliqAuth });
4
+ }
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
+ }
@@ -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
+ }