@kevludwig/lexware-office 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 Kevin Ludwig
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,96 @@
1
+ # @kevludwig/lexware-office
2
+
3
+ A typed client and the domain logic for the [Lexware Office](https://www.lexware.de/lexware-office/) Public API (formerly lexoffice): contacts, vouchers, sales documents, posting categories, overdue receivables, and the rules behind payment reminders.
4
+
5
+ No framework, no agent, no environment variables — you pass the key, the store, and the mailer. For an agent, see [`@kevludwig/eve-lexware-office`](../eve-lexware-office).
6
+
7
+ ```bash
8
+ npm install @kevludwig/lexware-office
9
+ ```
10
+
11
+ Node 24 or newer, ESM only.
12
+
13
+ ## The client
14
+
15
+ ```ts
16
+ import { createLexwareClient } from "@kevludwig/lexware-office";
17
+
18
+ const client = createLexwareClient({ apiKey: process.env.LEXWARE_API_KEY! });
19
+ ```
20
+
21
+ One client is one rate-limit queue: requests are spaced (the API allows two per second), a 429 waits and is retried once, and each request has a time limit that starts when it actually fires. Several accounts are several clients — every function takes the client as its first argument.
22
+
23
+ | Option | Default | What for |
24
+ |---|---|---|
25
+ | `apiKey` | — | Public API key, from Lexware Office under "Erweiterungen → Public API" |
26
+ | `baseUrl` | `https://api.lexware.io/v1` | The old `api.lexoffice.io/v1` still works |
27
+ | `appUrl` | `https://app.lexware.de` | Base of the links `voucherUrl()` builds |
28
+ | `minSpacingMs` | `550` | Spacing between two requests |
29
+ | `timeoutMs` | `20000` | Per request, counted from when it fires |
30
+ | `rateLimitBackoffMs` | `1000` | Wait before the retry after a 429 |
31
+ | `fetch` | global `fetch` | Your own implementation, e.g. in tests |
32
+
33
+ A failed request throws `LexwareApiError` with `status` and `responseBody`; `describeError(error)` turns it into a sentence worth showing a person — a 403 says which permission the key lacks, a 406 what the API usually means by it.
34
+
35
+ ## What is in it
36
+
37
+ **Contacts** — `getContact`, `findContactByNumber`, `findContactsByName`, `resolveContactByName` (exact name first, then the single candidate), `contactEmail`, `createCustomer`.
38
+
39
+ **Sales documents** — `createSalesDocument` for quotations, order confirmations, and invoices, with `finalize`, a total discount, and `precedingSalesVoucherId` for a document chain; `getSalesDocument` (line items, totals, discounts, customer), `invoiceExists`, `findRecentSalesDocuments`, `findDuplicateSalesDocument` (same net total, same customer, within a window), `isPursueRejection` for the 406 an unusable chain answers with.
40
+
41
+ **Purchase invoices** — `createVoucher`, `uploadVoucherFile` for the original, `findVouchers`, `findPurchaseInvoiceByNumber` for a document already captured, `findVendorCategoryHistory` for how this vendor's invoices were booked so far.
42
+
43
+ **Posting categories** — `listPostingCategories` (cached per client), `resolveCategory` by name or id and direction, `isReverseChargeCategory` for §13b.
44
+
45
+ **Receivables** — `findOverdueInvoices`, `findOverdueInvoice`, `downloadInvoicePdf`.
46
+
47
+ **Payment reminders** — the rules below.
48
+
49
+ **Money and dates** — `netTotal`, `round2`, `taxFromGross`, `taxFromNet`, the tolerances `ITEMS_TOTAL_TOLERANCE` and `DUPLICATE_TOTAL_TOLERANCE`, and `berlinDay`, `daysSince`, `addDays`.
50
+
51
+ ## Payment reminders
52
+
53
+ `selectDueReminders` decides in code, not by judgment, which overdue invoices are due for a reminder:
54
+
55
+ ```ts
56
+ import { defaultReminderMail, reminderLedger, selectDueReminders, sendReminder, vercelBlobStore } from "@kevludwig/lexware-office";
57
+
58
+ const ledger = reminderLedger(vercelBlobStore({ prefix: "lexware-office" }), "reminders/live");
59
+ const { due, held } = await selectDueReminders(client, ledger);
60
+
61
+ for (const { invoice, customerEmail } of due) {
62
+ await sendReminder(
63
+ client,
64
+ ledger,
65
+ { ...invoice, invoiceId: invoice.id, to: customerEmail, test: false },
66
+ { render: defaultReminderMail({ name: "Beispiel GmbH" }), sendMail: yourMailer, owner: "nightly-run" },
67
+ );
68
+ }
69
+ ```
70
+
71
+ An invoice is offered when it is past its reminder due date by `minDaysOverdue`, has not been reminded, is not waiting for a decision somewhere else, was not declined recently, and its customer has an address. An invoice "due immediately" — Lexware Office sets the due date to the invoice date — gets `immediatePaymentTermDays` first. Everything else comes back in `held`, each with the reason.
72
+
73
+ | Policy | Default | |
74
+ |---|---|---|
75
+ | `minDaysOverdue` | `3` | Buffer after the reminder due date |
76
+ | `immediatePaymentTermDays` | `14` | Payment term assumed for an invoice due immediately |
77
+ | `pendingValidHours` | `20` | How long a reminder waiting for approval blocks the invoice |
78
+ | `declinedQuietDays` | `7` | Quiet after a declined reminder |
79
+
80
+ `sendReminder` claims the send round in the ledger **before** the mail goes out, so "at most once per round" holds across parallel cards, a repeated schedule run, and a replayed step. If your mailer throws `MailNotSentError`, the claim is given back — anything else counts as possibly sent and stays claimed. The mail itself is yours: `sendMail` delivers it, `render` builds subject and body (the default is a friendly German reminder with the invoice PDF attached, signed by `sender`).
81
+
82
+ ## Storage
83
+
84
+ The ledger needs somewhere to keep one small JSON record per invoice. `JsonStore` is four methods — `read`, `write`, `create` (which must fail when the key exists, that is what makes a claim a claim), `delete`:
85
+
86
+ - `memoryStore()` — tests and scripts.
87
+ - `vercelBlobStore({ prefix })` — Vercel Blob, needs `BLOB_READ_WRITE_TOKEN` (or a `token`). Imported from `@kevludwig/lexware-office/vercel-blob`, with `@vercel/blob` as an optional peer.
88
+ - Your own, for Redis, S3, Postgres, a file — whatever makes `create` atomic.
89
+
90
+ ## Tests
91
+
92
+ `npm test` runs 38 tests against a fake API — no account, no network. `test/fake-api.ts` shows the pattern: routes answer by method and path, and every call is recorded.
93
+
94
+ ## Not affiliated with Lexware
95
+
96
+ An independent open-source project, MIT licensed. Not made, endorsed, or supported by Haufe-Lexware or its group; the names are used only to say which API this speaks to.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Posting categories (`/posting-categories`).
3
+ *
4
+ * Every voucher item carries a category. `income` applies to outgoing
5
+ * vouchers, `outgo` to incoming ones — the API rejects a purchase invoice with
6
+ * an income category. The list is per account and practically never changes;
7
+ * it is cached per client.
8
+ */
9
+ import type { LexwareClient } from "./client.ts";
10
+ export interface PostingCategory {
11
+ id: string;
12
+ name: string;
13
+ type: "income" | "outgo";
14
+ /** Whether the category demands a real contact on the voucher. */
15
+ contactRequired: boolean;
16
+ /** Whether items with different tax rates are allowed. */
17
+ splitAllowed: boolean;
18
+ groupName?: string;
19
+ }
20
+ /**
21
+ * The rate a reverse-charge item is booked with (§13b UStG): the recipient
22
+ * owes the VAT and deducts it again. Lexware Office books both sides through
23
+ * the category — the item carries this rate with a tax amount of 0, the gross
24
+ * is what the supplier was paid. A 0 % item is rejected (`invalid_taxrate_0`).
25
+ */
26
+ export declare const REVERSE_CHARGE_TAX_RATE = 19;
27
+ /** Whether a category books as reverse charge — the API has no flag, the name is the only marker ("… §13b …"). */
28
+ export declare function isReverseChargeCategory(name: string | undefined): boolean;
29
+ /** All posting categories of the account behind this client. */
30
+ export declare function listPostingCategories(client: LexwareClient, signal?: AbortSignal): Promise<readonly PostingCategory[]>;
31
+ /**
32
+ * A category of the given direction by name or id — exact name first, then a
33
+ * name containing it. A passed id is checked against the list, so a category
34
+ * of the wrong direction fails here, not with a 406 at creation. Null means
35
+ * "not resolvable", not "error".
36
+ */
37
+ export declare function resolveCategory(client: LexwareClient, nameOrId: string, type: PostingCategory["type"], signal?: AbortSignal): Promise<PostingCategory | null>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Posting categories (`/posting-categories`).
3
+ *
4
+ * Every voucher item carries a category. `income` applies to outgoing
5
+ * vouchers, `outgo` to incoming ones — the API rejects a purchase invoice with
6
+ * an income category. The list is per account and practically never changes;
7
+ * it is cached per client.
8
+ */
9
+ /**
10
+ * The rate a reverse-charge item is booked with (§13b UStG): the recipient
11
+ * owes the VAT and deducts it again. Lexware Office books both sides through
12
+ * the category — the item carries this rate with a tax amount of 0, the gross
13
+ * is what the supplier was paid. A 0 % item is rejected (`invalid_taxrate_0`).
14
+ */
15
+ export const REVERSE_CHARGE_TAX_RATE = 19;
16
+ /** Whether a category books as reverse charge — the API has no flag, the name is the only marker ("… §13b …"). */
17
+ export function isReverseChargeCategory(name) {
18
+ return Boolean(name && /§\s*13b/i.test(name));
19
+ }
20
+ const cache = new WeakMap();
21
+ /** All posting categories of the account behind this client. */
22
+ export async function listPostingCategories(client, signal) {
23
+ const cached = cache.get(client);
24
+ if (cached)
25
+ return cached;
26
+ const categories = (await client.request("/posting-categories", { signal })) ?? [];
27
+ cache.set(client, categories);
28
+ return categories;
29
+ }
30
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
31
+ /**
32
+ * A category of the given direction by name or id — exact name first, then a
33
+ * name containing it. A passed id is checked against the list, so a category
34
+ * of the wrong direction fails here, not with a 406 at creation. Null means
35
+ * "not resolvable", not "error".
36
+ */
37
+ export async function resolveCategory(client, nameOrId, type, signal) {
38
+ const needle = nameOrId.trim();
39
+ if (!needle)
40
+ return null;
41
+ const matching = (await listPostingCategories(client, signal)).filter((category) => category.type === type);
42
+ if (UUID.test(needle))
43
+ return matching.find((category) => category.id.toLowerCase() === needle.toLowerCase()) ?? null;
44
+ const lower = needle.toLowerCase();
45
+ return (matching.find((category) => category.name.toLowerCase() === lower) ??
46
+ matching.find((category) => category.name.toLowerCase().includes(lower)) ??
47
+ null);
48
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The HTTP layer of the Lexware Office Public API.
3
+ *
4
+ * One client per account: the API allows two requests per second for the
5
+ * whole account, so every call of a client goes through one serial queue with
6
+ * a minimum spacing. A single retry on 429 covers the case that something else
7
+ * uses the same account at the same time. Share one client instead of creating
8
+ * one per call — separate clients do not know about each other's requests.
9
+ */
10
+ export declare const DEFAULT_BASE_URL = "https://api.lexware.io/v1";
11
+ /** Where a voucher opens in the Lexware Office web app. */
12
+ export declare const DEFAULT_APP_URL = "https://app.lexware.de";
13
+ export interface LexwareClientOptions {
14
+ /** A Public API key, created in Lexware Office under "Erweiterungen → Public API". */
15
+ apiKey: string;
16
+ /** API base URL. Default: https://api.lexware.io/v1 (the former api.lexoffice.io/v1 still works). */
17
+ baseUrl?: string;
18
+ /** Web app URL for links to vouchers. Default: https://app.lexware.de */
19
+ appUrl?: string;
20
+ /** Minimum spacing between two requests in ms. Default: 550 (the API allows 2 per second). */
21
+ minSpacingMs?: number;
22
+ /** Time limit per request in ms, counted from when it actually fires. Default: 20 000. */
23
+ timeoutMs?: number;
24
+ /** Wait before retrying a request answered with 429, in ms. Default: 1 000. */
25
+ rateLimitBackoffMs?: number;
26
+ /** A fetch implementation, e.g. for tests. Default: the global fetch. */
27
+ fetch?: typeof fetch;
28
+ }
29
+ export interface FileUpload {
30
+ bytes: Uint8Array;
31
+ filename: string;
32
+ mediaType: string;
33
+ }
34
+ export interface LexwareClient {
35
+ /** A JSON request. `json` sets the body and content type. */
36
+ request<T = unknown>(path: string, init?: RequestInit & {
37
+ json?: unknown;
38
+ }): Promise<T>;
39
+ /** A binary download, e.g. a document's PDF. */
40
+ download(path: string, init: RequestInit & {
41
+ accept: string;
42
+ }): Promise<{
43
+ bytes: Uint8Array;
44
+ mediaType: string;
45
+ }>;
46
+ /** A multipart file upload; the API calls the field `file`. */
47
+ upload<T = unknown>(path: string, file: FileUpload, init?: RequestInit): Promise<T>;
48
+ /** The link to a voucher or invoice in the web app. */
49
+ voucherUrl(id: string): string;
50
+ }
51
+ /**
52
+ * An error answer of the API, with status and body.
53
+ *
54
+ * The status matters most: 403 nearly always means "the key lacks a
55
+ * permission for this endpoint", not "the request was wrong".
56
+ */
57
+ export declare class LexwareApiError extends Error {
58
+ readonly status: number;
59
+ readonly method: string;
60
+ readonly path: string;
61
+ readonly responseBody: string;
62
+ constructor(options: {
63
+ status: number;
64
+ statusText: string;
65
+ method: string;
66
+ path: string;
67
+ responseBody: string;
68
+ });
69
+ }
70
+ /** An error as a sentence, with the hint that is nearly always right for its status. */
71
+ export declare function describeError(error: unknown): string;
72
+ /** A query string from an object; empty values drop out. */
73
+ export declare function query(params: Record<string, string | number | boolean | undefined>): string;
74
+ export declare function createLexwareClient(options: LexwareClientOptions): LexwareClient;
package/dist/client.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The HTTP layer of the Lexware Office Public API.
3
+ *
4
+ * One client per account: the API allows two requests per second for the
5
+ * whole account, so every call of a client goes through one serial queue with
6
+ * a minimum spacing. A single retry on 429 covers the case that something else
7
+ * uses the same account at the same time. Share one client instead of creating
8
+ * one per call — separate clients do not know about each other's requests.
9
+ */
10
+ export const DEFAULT_BASE_URL = "https://api.lexware.io/v1";
11
+ /** Where a voucher opens in the Lexware Office web app. */
12
+ export const DEFAULT_APP_URL = "https://app.lexware.de";
13
+ /**
14
+ * An error answer of the API, with status and body.
15
+ *
16
+ * The status matters most: 403 nearly always means "the key lacks a
17
+ * permission for this endpoint", not "the request was wrong".
18
+ */
19
+ export class LexwareApiError extends Error {
20
+ status;
21
+ method;
22
+ path;
23
+ responseBody;
24
+ constructor(options) {
25
+ super(`Lexware API ${options.status} ${options.statusText} bei ${options.method} ${options.path}` +
26
+ (options.responseBody ? `: ${options.responseBody.slice(0, 500)}` : ""));
27
+ this.name = "LexwareApiError";
28
+ this.status = options.status;
29
+ this.method = options.method;
30
+ this.path = options.path;
31
+ this.responseBody = options.responseBody;
32
+ }
33
+ }
34
+ /** An error as a sentence, with the hint that is nearly always right for its status. */
35
+ export function describeError(error) {
36
+ if (!(error instanceof LexwareApiError))
37
+ return error instanceof Error ? error.message : String(error);
38
+ const hint = error.status === 403
39
+ ? " Ein 403 heißt: dem API-Schlüssel fehlt das Recht für diesen Endpunkt."
40
+ : error.status === 406
41
+ ? " Ein 406 heißt meist: ein Pflichtfeld fehlt oder ein Betrag passt nicht zur Summe."
42
+ : "";
43
+ return `${error.message}${hint}`;
44
+ }
45
+ /** A query string from an object; empty values drop out. */
46
+ export function query(params) {
47
+ const search = new URLSearchParams();
48
+ for (const [key, value] of Object.entries(params)) {
49
+ if (value === undefined || value === "")
50
+ continue;
51
+ search.set(key, String(value));
52
+ }
53
+ const rendered = search.toString();
54
+ return rendered ? `?${rendered}` : "";
55
+ }
56
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
57
+ export function createLexwareClient(options) {
58
+ if (!options.apiKey?.trim())
59
+ throw new Error("Lexware Office: apiKey fehlt.");
60
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
61
+ const appUrl = (options.appUrl ?? DEFAULT_APP_URL).replace(/\/+$/, "");
62
+ const minSpacingMs = options.minSpacingMs ?? 550;
63
+ const timeoutMs = options.timeoutMs ?? 20_000;
64
+ const backoffMs = options.rateLimitBackoffMs ?? 1_000;
65
+ const fetchImpl = options.fetch ?? fetch;
66
+ // The queue: each request waits for the previous one to start plus the
67
+ // spacing. The time limit starts when the request fires, not when queued.
68
+ let chain = Promise.resolve();
69
+ let lastStart = 0;
70
+ function schedule(task) {
71
+ const run = chain.then(async () => {
72
+ const wait = minSpacingMs - (Date.now() - lastStart);
73
+ if (wait > 0)
74
+ await sleep(wait);
75
+ lastStart = Date.now();
76
+ return task();
77
+ });
78
+ // An error belongs to its caller, not to the queue.
79
+ chain = run.then(() => undefined, () => undefined);
80
+ return run;
81
+ }
82
+ /**
83
+ * One request, queued, with a single retry on 429 — then the API
84
+ * demonstrably did not run it. A transport error after a POST is never
85
+ * retried: the voucher may well exist already.
86
+ */
87
+ async function send(path, init) {
88
+ const method = init.method ?? "GET";
89
+ const fire = () => schedule(() => {
90
+ const timeout = AbortSignal.timeout(timeoutMs);
91
+ return fetchImpl(`${baseUrl}${path}`, {
92
+ ...init,
93
+ headers: { Authorization: `Bearer ${options.apiKey}`, Accept: "application/json", ...init.headers },
94
+ signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout,
95
+ });
96
+ });
97
+ let response = await fire();
98
+ if (response.status === 429) {
99
+ await sleep(backoffMs);
100
+ response = await fire();
101
+ }
102
+ if (!response.ok) {
103
+ const body = await response.text().catch(() => "");
104
+ throw new LexwareApiError({ status: response.status, statusText: response.statusText, method, path, responseBody: body });
105
+ }
106
+ return response;
107
+ }
108
+ async function json(response) {
109
+ // Not every endpoint answers with a body (204).
110
+ const text = await response.text();
111
+ return (text ? JSON.parse(text) : undefined);
112
+ }
113
+ return {
114
+ async request(path, init = {}) {
115
+ const { json: body, ...rest } = init;
116
+ const response = await send(path, {
117
+ ...rest,
118
+ ...(body === undefined
119
+ ? {}
120
+ : { body: JSON.stringify(body), headers: { "Content-Type": "application/json", ...rest.headers } }),
121
+ });
122
+ return json(response);
123
+ },
124
+ async download(path, init) {
125
+ const { accept, ...rest } = init;
126
+ const response = await send(path, { ...rest, headers: { ...rest.headers, Accept: accept } });
127
+ return {
128
+ bytes: new Uint8Array(await response.arrayBuffer()),
129
+ mediaType: response.headers.get("content-type") ?? accept,
130
+ };
131
+ },
132
+ async upload(path, file, init = {}) {
133
+ const form = new FormData();
134
+ // An own ArrayBuffer: Blob refuses shared buffers.
135
+ form.set("file", new Blob([Uint8Array.from(file.bytes)], { type: file.mediaType }), file.filename);
136
+ // No Content-Type: fetch sets the multipart boundary.
137
+ return json(await send(path, { ...init, method: "POST", body: form }));
138
+ },
139
+ voucherUrl(id) {
140
+ return `${appUrl}/vouchers#!/VoucherDetail/${id}`;
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Contacts — customers and vendors alike.
3
+ *
4
+ * Search is fuzzy on the API side: "Nordlicht" finds "Nordlicht Consulting
5
+ * GmbH", "Müller" may find three. Functions here return candidates and leave
6
+ * the choice to the caller; guessing one of several is the worse mistake.
7
+ */
8
+ import { type LexwareClient } from "./client.ts";
9
+ export interface Contact {
10
+ id: string;
11
+ /** Company name, or first and last name of a person. */
12
+ name: string;
13
+ /** Customer or vendor number, where assigned. */
14
+ number?: number;
15
+ isCustomer: boolean;
16
+ isVendor: boolean;
17
+ }
18
+ /** A contact by its id. */
19
+ export declare function getContact(client: LexwareClient, id: string, signal?: AbortSignal): Promise<Contact>;
20
+ /** The contact with this customer number, or null. */
21
+ export declare function findContactByNumber(client: LexwareClient, customerNumber: number, signal?: AbortSignal): Promise<Contact | null>;
22
+ /** Contacts whose name matches — fuzzy, up to `size` (default 10). */
23
+ export declare function findContactsByName(client: LexwareClient, name: string, options?: {
24
+ customer?: boolean;
25
+ vendor?: boolean;
26
+ size?: number;
27
+ signal?: AbortSignal;
28
+ }): Promise<Contact[]>;
29
+ /**
30
+ * The one contact a name clearly means: an exact match, or the only
31
+ * candidate. Otherwise `match` is null and the candidates are for the caller
32
+ * to choose from.
33
+ */
34
+ export declare function resolveContactByName(client: LexwareClient, name: string, options?: {
35
+ customer?: boolean;
36
+ vendor?: boolean;
37
+ signal?: AbortSignal;
38
+ }): Promise<{
39
+ match: Contact | null;
40
+ candidates: Contact[];
41
+ }>;
42
+ /** Where a contact receives mail: a business address first, then office, other, private. */
43
+ export declare function contactEmail(client: LexwareClient, id: string, signal?: AbortSignal): Promise<string | null>;
44
+ export interface NewCustomer {
45
+ /** Exactly one of company or person. */
46
+ company?: {
47
+ name: string;
48
+ };
49
+ person?: {
50
+ firstName?: string;
51
+ lastName: string;
52
+ };
53
+ billingAddress?: {
54
+ supplement?: string;
55
+ street?: string;
56
+ zip?: string;
57
+ city?: string;
58
+ /** ISO 3166-1 alpha-2. Default: DE. */
59
+ countryCode?: string;
60
+ };
61
+ email?: string;
62
+ phone?: string;
63
+ /** Internal note, invisible on documents. */
64
+ note?: string;
65
+ }
66
+ export interface CreatedCustomer {
67
+ id: string;
68
+ /** Assigned by Lexware Office on creation; missing if the read-back failed. */
69
+ customerNumber?: number;
70
+ /** The name as stored. */
71
+ name: string;
72
+ }
73
+ /**
74
+ * Creates a customer and reads it back: the customer number is assigned
75
+ * server-side, and the stored name is what belongs in a confirmation. Needs
76
+ * the key permission "Kontakte bearbeiten".
77
+ */
78
+ export declare function createCustomer(client: LexwareClient, input: NewCustomer, signal?: AbortSignal): Promise<CreatedCustomer>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Contacts — customers and vendors alike.
3
+ *
4
+ * Search is fuzzy on the API side: "Nordlicht" finds "Nordlicht Consulting
5
+ * GmbH", "Müller" may find three. Functions here return candidates and leave
6
+ * the choice to the caller; guessing one of several is the worse mistake.
7
+ */
8
+ import { query } from "./client.js";
9
+ function nameOf(raw) {
10
+ return raw.company?.name ?? [raw.person?.firstName, raw.person?.lastName].filter(Boolean).join(" ");
11
+ }
12
+ function toContact(raw) {
13
+ return {
14
+ id: raw.id,
15
+ name: nameOf(raw) || "unbekannt",
16
+ number: raw.roles?.customer?.number ?? raw.roles?.vendor?.number,
17
+ isCustomer: raw.roles?.customer !== undefined,
18
+ isVendor: raw.roles?.vendor !== undefined,
19
+ };
20
+ }
21
+ /** A contact by its id. */
22
+ export async function getContact(client, id, signal) {
23
+ return toContact(await client.request(`/contacts/${encodeURIComponent(id)}`, { signal }));
24
+ }
25
+ /** The contact with this customer number, or null. */
26
+ export async function findContactByNumber(client, customerNumber, signal) {
27
+ const page = await client.request(`/contacts${query({ number: customerNumber })}`, { signal });
28
+ const raw = page.content?.[0];
29
+ return raw ? toContact(raw) : null;
30
+ }
31
+ /** Contacts whose name matches — fuzzy, up to `size` (default 10). */
32
+ export async function findContactsByName(client, name, options = {}) {
33
+ const page = await client.request(`/contacts${query({ name, customer: options.customer, vendor: options.vendor, size: options.size ?? 10 })}`, { signal: options.signal });
34
+ return (page.content ?? []).map(toContact);
35
+ }
36
+ /**
37
+ * The one contact a name clearly means: an exact match, or the only
38
+ * candidate. Otherwise `match` is null and the candidates are for the caller
39
+ * to choose from.
40
+ */
41
+ export async function resolveContactByName(client, name, options = {}) {
42
+ const needle = name.trim();
43
+ if (!needle)
44
+ return { match: null, candidates: [] };
45
+ const candidates = await findContactsByName(client, needle, options);
46
+ const exact = candidates.filter((contact) => contact.name.toLowerCase() === needle.toLowerCase());
47
+ if (exact.length === 1)
48
+ return { match: exact[0], candidates };
49
+ if (candidates.length === 1)
50
+ return { match: candidates[0], candidates };
51
+ return { match: null, candidates };
52
+ }
53
+ /** Where a contact receives mail: a business address first, then office, other, private. */
54
+ export async function contactEmail(client, id, signal) {
55
+ const raw = await client.request(`/contacts/${encodeURIComponent(id)}`, { signal });
56
+ const addresses = raw.emailAddresses ?? {};
57
+ return ([...(addresses.business ?? []), ...(addresses.office ?? []), ...(addresses.other ?? []), ...(addresses.private ?? [])]
58
+ .map((address) => address.trim())
59
+ .find((address) => address.includes("@")) ?? null);
60
+ }
61
+ /**
62
+ * Creates a customer and reads it back: the customer number is assigned
63
+ * server-side, and the stored name is what belongs in a confirmation. Needs
64
+ * the key permission "Kontakte bearbeiten".
65
+ */
66
+ export async function createCustomer(client, input, signal) {
67
+ if (Boolean(input.company) === Boolean(input.person)) {
68
+ throw new Error("Ein Kunde ist entweder eine Firma oder eine Person.");
69
+ }
70
+ const address = input.billingAddress;
71
+ const payload = {
72
+ version: 0,
73
+ roles: { customer: {} },
74
+ ...(input.company ? { company: { name: input.company.name } } : {}),
75
+ ...(input.person
76
+ ? { person: { ...(input.person.firstName ? { firstName: input.person.firstName } : {}), lastName: input.person.lastName } }
77
+ : {}),
78
+ ...(address
79
+ ? {
80
+ addresses: {
81
+ billing: [
82
+ {
83
+ ...(address.supplement ? { supplement: address.supplement } : {}),
84
+ ...(address.street ? { street: address.street } : {}),
85
+ ...(address.zip ? { zip: address.zip } : {}),
86
+ ...(address.city ? { city: address.city } : {}),
87
+ countryCode: address.countryCode ?? "DE",
88
+ },
89
+ ],
90
+ },
91
+ }
92
+ : {}),
93
+ ...(input.email ? { emailAddresses: { business: [input.email] } } : {}),
94
+ ...(input.phone ? { phoneNumbers: { business: [input.phone] } } : {}),
95
+ ...(input.note ? { note: input.note } : {}),
96
+ };
97
+ const created = await client.request("/contacts", { method: "POST", json: payload, signal });
98
+ const fallbackName = input.company?.name ?? input.person?.lastName ?? "unbekannt";
99
+ try {
100
+ const detail = await client.request(`/contacts/${created.id}`, { signal });
101
+ return { id: created.id, customerNumber: detail.roles?.customer?.number, name: nameOf(detail) || fallbackName };
102
+ }
103
+ catch {
104
+ // The customer exists; a failed read-back must not look like a failed creation.
105
+ return { id: created.id, name: fallbackName };
106
+ }
107
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Calendar days as Lexware Office users count them: in Berlin. A due date is
3
+ * a day, not a moment — "3 days overdue" must not depend on whether the check
4
+ * runs at 00:30 or 23:30.
5
+ */
6
+ /** Today (or `at`) as a day in Berlin, yyyy-MM-dd. */
7
+ export declare function berlinDay(at?: Date): string;
8
+ /** Days from a day (yyyy-MM-dd) to today in Berlin; negative while still ahead. */
9
+ export declare function daysSince(day: string, today?: Date): number;
10
+ /** A day (yyyy-MM-dd) moved by whole days. */
11
+ export declare function addDays(day: string, days: number): string;
package/dist/dates.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Calendar days as Lexware Office users count them: in Berlin. A due date is
3
+ * a day, not a moment — "3 days overdue" must not depend on whether the check
4
+ * runs at 00:30 or 23:30.
5
+ */
6
+ /** Today (or `at`) as a day in Berlin, yyyy-MM-dd. */
7
+ export function berlinDay(at = new Date()) {
8
+ return at.toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" });
9
+ }
10
+ /** Days from a day (yyyy-MM-dd) to today in Berlin; negative while still ahead. */
11
+ export function daysSince(day, today = new Date()) {
12
+ const from = Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
13
+ const to = Date.parse(`${berlinDay(today)}T00:00:00Z`);
14
+ return Math.round((to - from) / 86_400_000);
15
+ }
16
+ /** A day (yyyy-MM-dd) moved by whole days. */
17
+ export function addDays(day, days) {
18
+ const date = new Date(`${day.slice(0, 10)}T00:00:00Z`);
19
+ date.setUTCDate(date.getUTCDate() + days);
20
+ return date.toISOString().slice(0, 10);
21
+ }
@@ -0,0 +1,11 @@
1
+ export { DEFAULT_APP_URL, DEFAULT_BASE_URL, LexwareApiError, createLexwareClient, describeError, query, type FileUpload, type LexwareClient, type LexwareClientOptions, } from "./client.ts";
2
+ export { contactEmail, createCustomer, findContactByNumber, findContactsByName, getContact, resolveContactByName, type Contact, type CreatedCustomer, type NewCustomer, } from "./contacts.ts";
3
+ export { addDays, berlinDay, daysSince } from "./dates.ts";
4
+ export { downloadInvoicePdf, findOverdueInvoice, findOverdueInvoices, type OverdueInvoice, } from "./receivables.ts";
5
+ export { DEFAULT_REMINDER_POLICY, daysPastReminderDue, earliestReminderDate, isDueImmediately, isOpenForReminder, reminderDueDate, reminderLedger, selectDueReminders, type DueReminder, type ReminderHold, type ReminderLedger, type ReminderPolicy, type ReminderRecord, type ReminderSelection, type ReminderStatus, sendReminder, type ReminderTarget, type SendReminderOutcome, } from "./reminders.ts";
6
+ export { MailNotSentError, defaultReminderMail, reminderSubject, type ReminderMail, type ReminderMailData, type ReminderSender, type RenderReminderMail, type SendReminderMail, } from "./reminder-mail.ts";
7
+ export { keySegment, memoryStore, type JsonStore } from "./store.ts";
8
+ export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, netTotal, round2, taxFromGross, taxFromNet, type PricedLine, } from "./money.ts";
9
+ export { REVERSE_CHARGE_TAX_RATE, isReverseChargeCategory, listPostingCategories, resolveCategory, type PostingCategory, } from "./categories.ts";
10
+ export { createVoucher, findPurchaseInvoiceByNumber, findVendorCategoryHistory, findVouchers, uploadVoucherFile, type CategoryHistory, type CreateVoucherOptions, type CreatedVoucher, type VoucherItem, type VoucherListEntry, type VoucherListFilter, type VoucherStatus, type VoucherTaxType, type VoucherType, } from "./vouchers.ts";
11
+ export { SALES_DOCUMENT_STATUSES, VOUCHERLIST_TYPE, createSalesDocument, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPursueRejection, type CreatedSalesDocument, type DuplicateSearchResult, type LineItem, type SalesDocumentDetail, type SalesDocumentKind, type SalesListEntry, } from "./sales-documents.ts";