beliq-sevdesk 0.1.0 → 0.2.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 +70 -0
- package/dist/args.js +1 -0
- package/dist/config.js +23 -0
- package/dist/help.js +4 -0
- package/dist/notify.js +44 -0
- package/dist/worker.js +27 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,6 +77,71 @@ A cron entry that runs it every 15 minutes:
|
|
|
77
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
78
|
```
|
|
79
79
|
|
|
80
|
+
## Run it in a container
|
|
81
|
+
|
|
82
|
+
A prebuilt multi-arch image (amd64 + arm64) is published to GitHub Container
|
|
83
|
+
Registry:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
docker pull ghcr.io/beliq-eu/beliq-sevdesk:latest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The image ships only the compiled worker and `@beliq/sdk` from the public npm
|
|
90
|
+
registry. No private beliq source is in it: all validation and conversion happen
|
|
91
|
+
on the beliq API over HTTPS.
|
|
92
|
+
|
|
93
|
+
The container's entrypoint is the worker, so arguments pass straight through. Run
|
|
94
|
+
a single poll:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
docker run --rm \
|
|
98
|
+
-e SEVDESK_API_TOKEN -e BELIQ_API_KEY -e SEVDESK_TARGET_FORMATS=peppol-bis \
|
|
99
|
+
-v "$PWD/out:/app/out" -v "$PWD/state:/app/state" \
|
|
100
|
+
ghcr.io/beliq-eu/beliq-sevdesk:latest --once
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Inside the image the state file defaults to `/app/state/state.json` and the
|
|
104
|
+
output dir to `/app/out`; mount volumes there to persist the high-water-mark and
|
|
105
|
+
the converted documents across restarts. With no arguments the container loops as
|
|
106
|
+
a daemon.
|
|
107
|
+
|
|
108
|
+
## Example recipes
|
|
109
|
+
|
|
110
|
+
Ready-to-copy deployment recipes are in [examples/](examples/):
|
|
111
|
+
|
|
112
|
+
- [docker-compose.yml](examples/docker-compose.yml) runs it as a restart-on-failure daemon.
|
|
113
|
+
- [beliq-sevdesk.service](examples/beliq-sevdesk.service) + [beliq-sevdesk.timer](examples/beliq-sevdesk.timer) run it natively on a systemd timer (no Docker).
|
|
114
|
+
- [github-actions-cron.yml](examples/github-actions-cron.yml) polls on a schedule from GitHub Actions, failing the run when an invoice fails.
|
|
115
|
+
|
|
116
|
+
## Notify on failures
|
|
117
|
+
|
|
118
|
+
Set a webhook to get a JSON report POSTed after a poll:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
export SEVDESK_NOTIFY_WEBHOOK=https://hooks.example.com/your/endpoint
|
|
122
|
+
# SEVDESK_NOTIFY_ON=failure (default) posts only when an invoice fails;
|
|
123
|
+
# SEVDESK_NOTIFY_ON=always posts after every poll (a heartbeat, good for cron).
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The body:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"ok": false,
|
|
131
|
+
"summary": "processed 2 invoice(s): 1 valid, 1 invalid, 0 error",
|
|
132
|
+
"counts": { "valid": 1, "invalid": 1, "error": 0 },
|
|
133
|
+
"invoices": [
|
|
134
|
+
{ "id": "10", "invoiceNumber": "INV-10", "classification": "valid" },
|
|
135
|
+
{ "id": "11", "invoiceNumber": "INV-11", "classification": "invalid" }
|
|
136
|
+
],
|
|
137
|
+
"polledAt": "2025-06-15T12:00:00.000Z"
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Notify is best-effort: a slow, dead, or non-2xx endpoint is logged (host only, so
|
|
142
|
+
a secret in the webhook path is never printed) and never changes the exit code.
|
|
143
|
+
The exit code always reflects the invoices, not the notification.
|
|
144
|
+
|
|
80
145
|
## Configuration
|
|
81
146
|
|
|
82
147
|
Every setting is read from the environment; the flags below override the matching
|
|
@@ -95,6 +160,8 @@ variable.
|
|
|
95
160
|
| `SEVDESK_POLL_INTERVAL_SECONDS` | `--interval` | `300` | Seconds between polls in daemon mode. |
|
|
96
161
|
| `SEVDESK_PAGE_SIZE` | | `100` | Page size for the invoice listing. |
|
|
97
162
|
| `SEVDESK_MAX_RETRIES` | | `4` | Retries on a sevDesk 429 / 5xx / network error. |
|
|
163
|
+
| `SEVDESK_NOTIFY_WEBHOOK` | `--notify-webhook` | (none) | POST a JSON poll report here. Empty = no notifications. |
|
|
164
|
+
| `SEVDESK_NOTIFY_ON` | | `failure` | `failure` (only on a failed invoice) or `always` (every poll). |
|
|
98
165
|
| `SEVDESK_BASE_URL` | | `https://api.sevdesk.de/api/v1` | Override for a mock or a future version. |
|
|
99
166
|
| `BELIQ_BASE_URL` | | `https://api.beliq.eu` | Override for a self-hosted beliq. |
|
|
100
167
|
| `BELIQ_AUTH` | | `header` | How the beliq key is sent: `header` (X-API-Key) or `bearer`. |
|
|
@@ -139,6 +206,9 @@ npm run build
|
|
|
139
206
|
npm test # unit tests, no network
|
|
140
207
|
npm run scrub:check # no em-dash
|
|
141
208
|
|
|
209
|
+
# build the container image locally:
|
|
210
|
+
docker build -t beliq-sevdesk .
|
|
211
|
+
|
|
142
212
|
# live smoke against the real sevDesk + beliq APIs (skipped without creds):
|
|
143
213
|
SEVDESK_API_TOKEN=... BELIQ_API_KEY=... npm run test:integration
|
|
144
214
|
```
|
package/dist/args.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -51,6 +51,27 @@ function resolveProfile(raw) {
|
|
|
51
51
|
}
|
|
52
52
|
return raw;
|
|
53
53
|
}
|
|
54
|
+
function resolveNotifyWebhook(raw) {
|
|
55
|
+
if (!raw)
|
|
56
|
+
return undefined;
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new ConfigError(`invalid notify webhook URL "${raw}"`);
|
|
63
|
+
}
|
|
64
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
65
|
+
throw new ConfigError(`notify webhook must be an http(s) URL, got "${parsed.protocol}"`);
|
|
66
|
+
}
|
|
67
|
+
return raw;
|
|
68
|
+
}
|
|
69
|
+
function resolveNotifyOn(raw) {
|
|
70
|
+
const value = (raw ?? 'failure').trim().toLowerCase();
|
|
71
|
+
if (value === 'failure' || value === 'always')
|
|
72
|
+
return value;
|
|
73
|
+
throw new ConfigError(`invalid SEVDESK_NOTIFY_ON "${raw}". Use "failure" or "always".`);
|
|
74
|
+
}
|
|
54
75
|
/**
|
|
55
76
|
* Resolve the typed worker config with precedence flag > env > default. Throws a
|
|
56
77
|
* ConfigError (mapped to EXIT.USAGE) for a missing credential or a bad value, so
|
|
@@ -85,5 +106,7 @@ export function resolveConfig(args, env = process.env) {
|
|
|
85
106
|
maxRetries: parseIntEnv(env.SEVDESK_MAX_RETRIES, 4, 'max retries'),
|
|
86
107
|
once: flagBool(args, 'once'),
|
|
87
108
|
dryRun: flagBool(args, 'dry-run'),
|
|
109
|
+
notifyWebhook: resolveNotifyWebhook(pick(args, 'notify-webhook', env, 'SEVDESK_NOTIFY_WEBHOOK')),
|
|
110
|
+
notifyOn: resolveNotifyOn(env.SEVDESK_NOTIFY_ON),
|
|
88
111
|
};
|
|
89
112
|
}
|
package/dist/help.js
CHANGED
|
@@ -24,6 +24,7 @@ Options:
|
|
|
24
24
|
--interval <seconds> seconds between polls in daemon mode (default: 300)
|
|
25
25
|
--sevdesk-token <token> sevDesk API token (default: SEVDESK_API_TOKEN)
|
|
26
26
|
--api-key <key> beliq API key (default: BELIQ_API_KEY)
|
|
27
|
+
--notify-webhook <url> POST a JSON poll report here (default: SEVDESK_NOTIFY_WEBHOOK)
|
|
27
28
|
-h, --help show this help
|
|
28
29
|
-v, --version show the version
|
|
29
30
|
|
|
@@ -36,4 +37,7 @@ Exit codes (meaningful with --once):
|
|
|
36
37
|
|
|
37
38
|
Set SEVDESK_API_TOKEN (Settings -> Advanced -> API) and BELIQ_API_KEY (beliq
|
|
38
39
|
dashboard -> API Keys). The sevDesk token never leaves this environment.
|
|
40
|
+
|
|
41
|
+
With a notify webhook set, SEVDESK_NOTIFY_ON=failure (default) POSTs only when an
|
|
42
|
+
invoice fails; SEVDESK_NOTIFY_ON=always POSTs after every poll.
|
|
39
43
|
`;
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort webhook timeout. Notify is a side channel; a slow or dead endpoint
|
|
3
|
+
* must never stall the worker or change the run's verdict.
|
|
4
|
+
*/
|
|
5
|
+
const NOTIFY_TIMEOUT_MS = 10_000;
|
|
6
|
+
/** Host only, so a webhook that carries a secret in its path is not logged. */
|
|
7
|
+
function safeHost(url) {
|
|
8
|
+
try {
|
|
9
|
+
return new URL(url).host;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return 'invalid-url';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* POST the poll report to the notify webhook. Best-effort by design: a non-2xx
|
|
17
|
+
* response, a network failure, or a timeout is logged (host only, never the full
|
|
18
|
+
* URL) and swallowed. This function never throws, so the exit code stays a
|
|
19
|
+
* faithful signal of the invoices themselves, not of the notification.
|
|
20
|
+
*/
|
|
21
|
+
export async function notify(url, report, deps) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timer = setTimeout(() => controller.abort(), NOTIFY_TIMEOUT_MS);
|
|
24
|
+
try {
|
|
25
|
+
const res = await deps.fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify(report),
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
});
|
|
31
|
+
if (res.ok) {
|
|
32
|
+
deps.log.info('notify', { host: safeHost(url), status: res.status });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
deps.log.error('notify.error', { host: safeHost(url), status: res.status });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
deps.log.error('notify.error', { host: safeHost(url), message: err.message });
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/worker.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { IoError } from './errors.js';
|
|
4
4
|
import { emptyCounts, summaryExitCode } from './exit.js';
|
|
5
5
|
import { loadState, saveState } from './state.js';
|
|
6
|
+
import { notify } from './notify.js';
|
|
6
7
|
/** Convert targets that produce a hybrid PDF rather than a standalone XML document. */
|
|
7
8
|
const PDF_TARGETS = new Set(['facturx', 'zugferd']);
|
|
8
9
|
/** Seconds in a day, for the poll-window date filter. */
|
|
@@ -101,6 +102,7 @@ export async function pollOnce(config, deps) {
|
|
|
101
102
|
fresh: fresh.length,
|
|
102
103
|
});
|
|
103
104
|
const counts = emptyCounts();
|
|
105
|
+
const outcomes = [];
|
|
104
106
|
let highWater = state.lastInvoiceId;
|
|
105
107
|
let blocked = false;
|
|
106
108
|
for (const { inv, idNum } of fresh) {
|
|
@@ -117,6 +119,7 @@ export async function pollOnce(config, deps) {
|
|
|
117
119
|
});
|
|
118
120
|
}
|
|
119
121
|
counts[classification]++;
|
|
122
|
+
outcomes.push({ id: inv.id, invoiceNumber: inv.invoiceNumber, classification });
|
|
120
123
|
if (classification === 'error')
|
|
121
124
|
blocked = true;
|
|
122
125
|
else if (!blocked)
|
|
@@ -128,9 +131,32 @@ export async function pollOnce(config, deps) {
|
|
|
128
131
|
lastPolledAt: new Date(now()).toISOString(),
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
|
-
|
|
134
|
+
const summary = formatSummary(counts, fresh.length, config.dryRun);
|
|
135
|
+
deps.log.summary(summary);
|
|
136
|
+
await maybeNotify(config, deps, counts, outcomes, summary, now);
|
|
132
137
|
return { counts, processedTo: highWater };
|
|
133
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Fire the notify webhook when configured. Skipped in a dry run (a dry run has no
|
|
141
|
+
* side effects). With notifyOn=failure it POSTs only when an invoice failed; with
|
|
142
|
+
* notifyOn=always it POSTs after every poll (a heartbeat, best for --once/cron).
|
|
143
|
+
*/
|
|
144
|
+
async function maybeNotify(config, deps, counts, outcomes, summary, now) {
|
|
145
|
+
const url = config.notifyWebhook;
|
|
146
|
+
if (!url || config.dryRun)
|
|
147
|
+
return;
|
|
148
|
+
const failed = counts.invalid + counts.error > 0;
|
|
149
|
+
if (config.notifyOn === 'failure' && !failed)
|
|
150
|
+
return;
|
|
151
|
+
const report = {
|
|
152
|
+
ok: !failed,
|
|
153
|
+
summary,
|
|
154
|
+
counts,
|
|
155
|
+
invoices: outcomes,
|
|
156
|
+
polledAt: new Date(now()).toISOString(),
|
|
157
|
+
};
|
|
158
|
+
await notify(url, report, { fetch: deps.fetch ?? fetch, log: deps.log });
|
|
159
|
+
}
|
|
134
160
|
/**
|
|
135
161
|
* Run the worker. With --once, poll a single time and return the exit code (the
|
|
136
162
|
* CI/cron contract). Otherwise loop forever, polling every interval, until the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beliq-sevdesk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|