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 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
@@ -15,6 +15,7 @@ const VALUE_FLAGS = new Set([
15
15
  'interval',
16
16
  'api-key',
17
17
  'sevdesk-token',
18
+ 'notify-webhook',
18
19
  ]);
19
20
  /**
20
21
  * Parse argv into flags. Hand-rolled (no dependency): the surface is small and
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
- deps.log.summary(formatSummary(counts, fresh.length, config.dryRun));
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.1.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",