@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 +21 -0
- package/README.md +96 -0
- package/dist/categories.d.ts +37 -0
- package/dist/categories.js +48 -0
- package/dist/client.d.ts +74 -0
- package/dist/client.js +143 -0
- package/dist/contacts.d.ts +78 -0
- package/dist/contacts.js +107 -0
- package/dist/dates.d.ts +11 -0
- package/dist/dates.js +21 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/money.d.ts +27 -0
- package/dist/money.js +30 -0
- package/dist/receivables.d.ts +40 -0
- package/dist/receivables.js +45 -0
- package/dist/reminder-mail.d.ts +63 -0
- package/dist/reminder-mail.js +76 -0
- package/dist/reminders.d.ts +156 -0
- package/dist/reminders.js +175 -0
- package/dist/sales-documents.d.ts +120 -0
- package/dist/sales-documents.js +149 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.js +38 -0
- package/dist/vercel-blob.d.ts +13 -0
- package/dist/vercel-blob.js +48 -0
- package/dist/vouchers.d.ts +125 -0
- package/dist/vouchers.js +98 -0
- package/package.json +63 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sales documents — quotation, order confirmation, invoice. All three share
|
|
3
|
+
* one payload shape; only the endpoint and a few optional fields differ.
|
|
4
|
+
*/
|
|
5
|
+
import { type LexwareClient } from "./client.ts";
|
|
6
|
+
export type SalesDocumentKind = "quotation" | "order-confirmation" | "invoice";
|
|
7
|
+
/** One line of a sales document. */
|
|
8
|
+
export interface LineItem {
|
|
9
|
+
name: string;
|
|
10
|
+
quantity: number;
|
|
11
|
+
/** e.g. "Stück", "Stunde". */
|
|
12
|
+
unit: string;
|
|
13
|
+
/** Net unit price in EUR. */
|
|
14
|
+
netPrice: number;
|
|
15
|
+
/** 0, 7, or 19. */
|
|
16
|
+
taxRate: number;
|
|
17
|
+
}
|
|
18
|
+
/** The type as the voucher list spells it. */
|
|
19
|
+
export declare const VOUCHERLIST_TYPE: Record<SalesDocumentKind, string>;
|
|
20
|
+
/**
|
|
21
|
+
* The live statuses of each kind in the voucher list. `voided` is left out: a
|
|
22
|
+
* cancelled document is neither a duplicate nor a source to derive from.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SALES_DOCUMENT_STATUSES: Record<SalesDocumentKind, string>;
|
|
25
|
+
export interface CreatedSalesDocument {
|
|
26
|
+
id: string;
|
|
27
|
+
resourceUri?: string;
|
|
28
|
+
url: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Creates a sales document. `finalize` gives it a number right away — it can
|
|
32
|
+
* then no longer be deleted like a draft. Neither `introduction` nor `remark`
|
|
33
|
+
* is set: a value would replace the default texts of the account's settings.
|
|
34
|
+
*
|
|
35
|
+
* `precedingSalesVoucherId` links the document to its predecessor (a pursued
|
|
36
|
+
* quotation becomes accepted) — only as a query parameter; in the body the API
|
|
37
|
+
* drops it silently. The content is not copied: this payload alone decides
|
|
38
|
+
* the new document. An invalid pursue answers 406, see isPursueRejection.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createSalesDocument(client: LexwareClient, options: {
|
|
41
|
+
kind: SalesDocumentKind;
|
|
42
|
+
contactId: string;
|
|
43
|
+
items: readonly LineItem[];
|
|
44
|
+
/** Replaces the printed heading; Lexware's default when absent. Max. 25 characters. */
|
|
45
|
+
title?: string;
|
|
46
|
+
/** Quotations: valid until, yyyy-MM-dd or a timestamp. */
|
|
47
|
+
expirationDate?: string;
|
|
48
|
+
finalize?: boolean;
|
|
49
|
+
/** Total discount in EUR on the net amount. */
|
|
50
|
+
totalDiscountAbsolute?: number;
|
|
51
|
+
precedingSalesVoucherId?: string;
|
|
52
|
+
}, signal?: AbortSignal): Promise<CreatedSalesDocument>;
|
|
53
|
+
/** Whether a 406 rejects the pursue, not the payload — then a retry without the link makes sense. */
|
|
54
|
+
export declare function isPursueRejection(error: unknown): boolean;
|
|
55
|
+
export interface SalesDocumentDetail {
|
|
56
|
+
id: string;
|
|
57
|
+
url: string;
|
|
58
|
+
voucherNumber?: string;
|
|
59
|
+
voucherStatus?: string;
|
|
60
|
+
contactId?: string;
|
|
61
|
+
contactName?: string;
|
|
62
|
+
/** Net total over the priced lines — NaN for a document without any. */
|
|
63
|
+
lineItemsNet: number;
|
|
64
|
+
lineItems: LineItem[];
|
|
65
|
+
/** The final net total — after a total discount. */
|
|
66
|
+
totalNet?: number;
|
|
67
|
+
/** A total discount is invisible in the lines. */
|
|
68
|
+
discountAbsolute?: number;
|
|
69
|
+
discountPercentage?: number;
|
|
70
|
+
}
|
|
71
|
+
/** A document reduced to the fields a comparison needs. Text lines (no price) are left out. */
|
|
72
|
+
export declare function getSalesDocument(client: LexwareClient, kind: SalesDocumentKind, id: string, signal?: AbortSignal): Promise<SalesDocumentDetail>;
|
|
73
|
+
/** Whether an invoice still exists. Only a 404 counts as gone — any other error answers true. */
|
|
74
|
+
export declare function invoiceExists(client: LexwareClient, id: string, signal?: AbortSignal): Promise<boolean>;
|
|
75
|
+
export interface SalesListEntry {
|
|
76
|
+
id: string;
|
|
77
|
+
url: string;
|
|
78
|
+
voucherNumber?: string;
|
|
79
|
+
voucherDate?: string;
|
|
80
|
+
voucherStatus?: string;
|
|
81
|
+
contactId?: string;
|
|
82
|
+
contactName?: string;
|
|
83
|
+
totalAmount?: number;
|
|
84
|
+
}
|
|
85
|
+
/** The most recent documents of a kind, newest first — how "the current quotation for X" is found. */
|
|
86
|
+
export declare function findRecentSalesDocuments(client: LexwareClient, options: {
|
|
87
|
+
kind: SalesDocumentKind;
|
|
88
|
+
contactId?: string;
|
|
89
|
+
statuses?: string;
|
|
90
|
+
lookbackDays?: number;
|
|
91
|
+
size?: number;
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
}): Promise<SalesListEntry[]>;
|
|
94
|
+
/** A search that could not check everything must not read as "nothing found". */
|
|
95
|
+
export type DuplicateSearchResult<T> = {
|
|
96
|
+
status: "match";
|
|
97
|
+
match: T;
|
|
98
|
+
scanned: number;
|
|
99
|
+
} | {
|
|
100
|
+
status: "clear";
|
|
101
|
+
scanned: number;
|
|
102
|
+
} | {
|
|
103
|
+
status: "incomplete";
|
|
104
|
+
scanned: number;
|
|
105
|
+
reason: string;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* A same-kind document for this contact with the same line-item net total —
|
|
109
|
+
* heuristic (sales documents carry no external reference), so a finding for
|
|
110
|
+
* the approval, never an automatic refusal. Each candidate is fetched in
|
|
111
|
+
* detail, capped at `maxCandidates`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function findDuplicateSalesDocument(client: LexwareClient, options: {
|
|
114
|
+
kind: SalesDocumentKind;
|
|
115
|
+
contactId: string;
|
|
116
|
+
itemsTotalNet: number;
|
|
117
|
+
lookbackDays?: number;
|
|
118
|
+
maxCandidates?: number;
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
}): Promise<DuplicateSearchResult<SalesListEntry>>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sales documents — quotation, order confirmation, invoice. All three share
|
|
3
|
+
* one payload shape; only the endpoint and a few optional fields differ.
|
|
4
|
+
*/
|
|
5
|
+
import { LexwareApiError, query } from "./client.js";
|
|
6
|
+
import { DUPLICATE_TOTAL_TOLERANCE, round2 } from "./money.js";
|
|
7
|
+
const ENDPOINTS = {
|
|
8
|
+
quotation: "/quotations",
|
|
9
|
+
"order-confirmation": "/order-confirmations",
|
|
10
|
+
invoice: "/invoices",
|
|
11
|
+
};
|
|
12
|
+
/** The type as the voucher list spells it. */
|
|
13
|
+
export const VOUCHERLIST_TYPE = {
|
|
14
|
+
quotation: "quotation",
|
|
15
|
+
"order-confirmation": "orderconfirmation",
|
|
16
|
+
invoice: "invoice",
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The live statuses of each kind in the voucher list. `voided` is left out: a
|
|
20
|
+
* cancelled document is neither a duplicate nor a source to derive from.
|
|
21
|
+
*/
|
|
22
|
+
export const SALES_DOCUMENT_STATUSES = {
|
|
23
|
+
quotation: "draft,open,accepted,rejected",
|
|
24
|
+
"order-confirmation": "draft,open",
|
|
25
|
+
invoice: "draft,open,paid,paidoff",
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Creates a sales document. `finalize` gives it a number right away — it can
|
|
29
|
+
* then no longer be deleted like a draft. Neither `introduction` nor `remark`
|
|
30
|
+
* is set: a value would replace the default texts of the account's settings.
|
|
31
|
+
*
|
|
32
|
+
* `precedingSalesVoucherId` links the document to its predecessor (a pursued
|
|
33
|
+
* quotation becomes accepted) — only as a query parameter; in the body the API
|
|
34
|
+
* drops it silently. The content is not copied: this payload alone decides
|
|
35
|
+
* the new document. An invalid pursue answers 406, see isPursueRejection.
|
|
36
|
+
*/
|
|
37
|
+
export async function createSalesDocument(client, options, signal) {
|
|
38
|
+
const voucherDate = new Date().toISOString().replace("Z", "+00:00");
|
|
39
|
+
const discount = options.totalDiscountAbsolute ?? 0;
|
|
40
|
+
const payload = {
|
|
41
|
+
voucherDate,
|
|
42
|
+
address: { contactId: options.contactId },
|
|
43
|
+
lineItems: options.items.map((item) => ({
|
|
44
|
+
type: "custom",
|
|
45
|
+
name: item.name,
|
|
46
|
+
quantity: item.quantity,
|
|
47
|
+
unitName: item.unit,
|
|
48
|
+
unitPrice: { currency: "EUR", netAmount: item.netPrice, taxRatePercentage: item.taxRate },
|
|
49
|
+
})),
|
|
50
|
+
totalPrice: { currency: "EUR", ...(discount > 0 ? { totalDiscountAbsolute: round2(discount) } : {}) },
|
|
51
|
+
taxConditions: { taxType: "net" },
|
|
52
|
+
shippingConditions: { shippingType: "delivery", shippingDate: voucherDate },
|
|
53
|
+
...(options.title ? { title: options.title } : {}),
|
|
54
|
+
...(options.expirationDate ? { expirationDate: toIsoDateTime(options.expirationDate) } : {}),
|
|
55
|
+
};
|
|
56
|
+
const params = query({ finalize: options.finalize ? true : undefined, precedingSalesVoucherId: options.precedingSalesVoucherId });
|
|
57
|
+
const result = await client.request(`${ENDPOINTS[options.kind]}${params}`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
json: payload,
|
|
60
|
+
signal,
|
|
61
|
+
});
|
|
62
|
+
return { id: result.id, resourceUri: result.resourceUri, url: client.voucherUrl(result.id) };
|
|
63
|
+
}
|
|
64
|
+
/** Whether a 406 rejects the pursue, not the payload — then a retry without the link makes sense. */
|
|
65
|
+
export function isPursueRejection(error) {
|
|
66
|
+
return error instanceof LexwareApiError && error.status === 406 && /preceding|pursue/i.test(error.responseBody);
|
|
67
|
+
}
|
|
68
|
+
/** yyyy-MM-dd as a timestamp at midnight in Berlin — the API expects datetimes. */
|
|
69
|
+
function toIsoDateTime(date) {
|
|
70
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
71
|
+
return date;
|
|
72
|
+
const offset = /GMT([+-]\d{2}:\d{2})/.exec(new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", timeZoneName: "longOffset" }).format(new Date(`${date}T12:00:00Z`)))?.[1] ?? "+01:00";
|
|
73
|
+
return `${date}T00:00:00.000${offset}`;
|
|
74
|
+
}
|
|
75
|
+
/** A document reduced to the fields a comparison needs. Text lines (no price) are left out. */
|
|
76
|
+
export async function getSalesDocument(client, kind, id, signal) {
|
|
77
|
+
const raw = await client.request(`${ENDPOINTS[kind]}/${encodeURIComponent(id)}`, { signal });
|
|
78
|
+
const items = (raw.lineItems ?? [])
|
|
79
|
+
.filter((item) => item.unitPrice !== undefined)
|
|
80
|
+
.map((item) => ({
|
|
81
|
+
name: item.name ?? "",
|
|
82
|
+
quantity: item.quantity ?? 0,
|
|
83
|
+
unit: item.unitName ?? "Stück",
|
|
84
|
+
netPrice: item.unitPrice?.netAmount ?? 0,
|
|
85
|
+
taxRate: item.unitPrice?.taxRatePercentage ?? 19,
|
|
86
|
+
}));
|
|
87
|
+
return {
|
|
88
|
+
id: raw.id,
|
|
89
|
+
url: client.voucherUrl(raw.id),
|
|
90
|
+
voucherNumber: raw.voucherNumber,
|
|
91
|
+
voucherStatus: raw.voucherStatus,
|
|
92
|
+
contactId: raw.address?.contactId,
|
|
93
|
+
contactName: raw.address?.name,
|
|
94
|
+
lineItemsNet: items.length === 0 ? Number.NaN : round2(items.reduce((sum, item) => sum + item.quantity * item.netPrice, 0)),
|
|
95
|
+
lineItems: items,
|
|
96
|
+
totalNet: raw.totalPrice?.totalNetAmount,
|
|
97
|
+
discountAbsolute: raw.totalPrice?.totalDiscountAbsolute,
|
|
98
|
+
discountPercentage: raw.totalPrice?.totalDiscountPercentage,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Whether an invoice still exists. Only a 404 counts as gone — any other error answers true. */
|
|
102
|
+
export async function invoiceExists(client, id, signal) {
|
|
103
|
+
try {
|
|
104
|
+
await client.request(`/invoices/${encodeURIComponent(id)}`, { signal });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return !(error instanceof LexwareApiError && error.status === 404);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** The most recent documents of a kind, newest first — how "the current quotation for X" is found. */
|
|
112
|
+
export async function findRecentSalesDocuments(client, options) {
|
|
113
|
+
const from = new Date(Date.now() - (options.lookbackDays ?? 180) * 86_400_000).toISOString().slice(0, 10);
|
|
114
|
+
const list = await client.request(`/voucherlist${query({
|
|
115
|
+
voucherType: VOUCHERLIST_TYPE[options.kind],
|
|
116
|
+
voucherStatus: options.statuses ?? SALES_DOCUMENT_STATUSES[options.kind],
|
|
117
|
+
contactId: options.contactId,
|
|
118
|
+
voucherDateFrom: from,
|
|
119
|
+
sort: "voucherDate,DESC",
|
|
120
|
+
size: options.size ?? 25,
|
|
121
|
+
})}`, { signal: options.signal });
|
|
122
|
+
return (list.content ?? []).map((entry) => ({ ...entry, url: client.voucherUrl(entry.id) }));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A same-kind document for this contact with the same line-item net total —
|
|
126
|
+
* heuristic (sales documents carry no external reference), so a finding for
|
|
127
|
+
* the approval, never an automatic refusal. Each candidate is fetched in
|
|
128
|
+
* detail, capped at `maxCandidates`.
|
|
129
|
+
*/
|
|
130
|
+
export async function findDuplicateSalesDocument(client, options) {
|
|
131
|
+
const candidates = await findRecentSalesDocuments(client, {
|
|
132
|
+
kind: options.kind,
|
|
133
|
+
contactId: options.contactId,
|
|
134
|
+
lookbackDays: options.lookbackDays ?? 90,
|
|
135
|
+
size: 50,
|
|
136
|
+
signal: options.signal,
|
|
137
|
+
});
|
|
138
|
+
const checked = candidates.slice(0, options.maxCandidates ?? 10);
|
|
139
|
+
for (const [index, candidate] of checked.entries()) {
|
|
140
|
+
const detail = await getSalesDocument(client, options.kind, candidate.id, options.signal);
|
|
141
|
+
if (Math.abs(detail.lineItemsNet - round2(options.itemsTotalNet)) <= DUPLICATE_TOTAL_TOLERANCE) {
|
|
142
|
+
return { status: "match", match: candidate, scanned: index + 1 };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (candidates.length > checked.length) {
|
|
146
|
+
return { status: "incomplete", scanned: checked.length, reason: `${candidates.length} Belege im Zeitfenster, nur die ${checked.length} neuesten geprüft` };
|
|
147
|
+
}
|
|
148
|
+
return { status: "clear", scanned: checked.length };
|
|
149
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where durable records live — the reminder ledger, send claims. A small JSON
|
|
3
|
+
* document store: read, write, create only if absent, delete.
|
|
4
|
+
*
|
|
5
|
+
* `create` must be atomic: two callers creating the same key, one wins. That
|
|
6
|
+
* is what keeps a reminder from going out twice. `memoryStore` is for tests
|
|
7
|
+
* and single-process use; `@kevludwig/lexware-office/vercel-blob` provides
|
|
8
|
+
* one on Vercel Blob.
|
|
9
|
+
*/
|
|
10
|
+
export interface JsonStore {
|
|
11
|
+
/** The document at `key`, or null if there is none. Throws when the store fails. */
|
|
12
|
+
read<T>(key: string): Promise<T | null>;
|
|
13
|
+
/** Writes the document, replacing what was there. */
|
|
14
|
+
write(key: string, value: unknown): Promise<void>;
|
|
15
|
+
/** Writes the document only if none exists; false when one does. */
|
|
16
|
+
create(key: string, value: unknown): Promise<boolean>;
|
|
17
|
+
/** Deletes the document; no error if there is none. */
|
|
18
|
+
delete(key: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** A key segment from an id: anything but word characters, dots, and dashes becomes "_". */
|
|
21
|
+
export declare function keySegment(value: string): string;
|
|
22
|
+
/** An in-memory store — for tests, or one process that never restarts. */
|
|
23
|
+
export declare function memoryStore(): JsonStore & {
|
|
24
|
+
readonly size: number;
|
|
25
|
+
};
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where durable records live — the reminder ledger, send claims. A small JSON
|
|
3
|
+
* document store: read, write, create only if absent, delete.
|
|
4
|
+
*
|
|
5
|
+
* `create` must be atomic: two callers creating the same key, one wins. That
|
|
6
|
+
* is what keeps a reminder from going out twice. `memoryStore` is for tests
|
|
7
|
+
* and single-process use; `@kevludwig/lexware-office/vercel-blob` provides
|
|
8
|
+
* one on Vercel Blob.
|
|
9
|
+
*/
|
|
10
|
+
/** A key segment from an id: anything but word characters, dots, and dashes becomes "_". */
|
|
11
|
+
export function keySegment(value) {
|
|
12
|
+
return value.trim().replace(/[^\w.-]+/g, "_");
|
|
13
|
+
}
|
|
14
|
+
/** An in-memory store — for tests, or one process that never restarts. */
|
|
15
|
+
export function memoryStore() {
|
|
16
|
+
const documents = new Map();
|
|
17
|
+
return {
|
|
18
|
+
get size() {
|
|
19
|
+
return documents.size;
|
|
20
|
+
},
|
|
21
|
+
async read(key) {
|
|
22
|
+
const value = documents.get(key);
|
|
23
|
+
return value === undefined ? null : JSON.parse(value);
|
|
24
|
+
},
|
|
25
|
+
async write(key, value) {
|
|
26
|
+
documents.set(key, JSON.stringify(value));
|
|
27
|
+
},
|
|
28
|
+
async create(key, value) {
|
|
29
|
+
if (documents.has(key))
|
|
30
|
+
return false;
|
|
31
|
+
documents.set(key, JSON.stringify(value));
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
async delete(key) {
|
|
35
|
+
documents.delete(key);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JsonStore on Vercel Blob. Documents are private, live at fixed pathnames
|
|
3
|
+
* under `prefix`, and are read past the CDN cache — records change within
|
|
4
|
+
* seconds of being read. `create` relies on Blob refusing to overwrite.
|
|
5
|
+
*/
|
|
6
|
+
import type { JsonStore } from "./store.ts";
|
|
7
|
+
export interface VercelBlobStoreOptions {
|
|
8
|
+
/** Pathname prefix, e.g. "lexware-office/production". Empty: keys are pathnames. */
|
|
9
|
+
prefix: string;
|
|
10
|
+
/** Read-write token. Default: BLOB_READ_WRITE_TOKEN from the environment. */
|
|
11
|
+
token?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function vercelBlobStore(options: VercelBlobStoreOptions): JsonStore;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JsonStore on Vercel Blob. Documents are private, live at fixed pathnames
|
|
3
|
+
* under `prefix`, and are read past the CDN cache — records change within
|
|
4
|
+
* seconds of being read. `create` relies on Blob refusing to overwrite.
|
|
5
|
+
*/
|
|
6
|
+
import { BlobError, del, get, put } from "@vercel/blob";
|
|
7
|
+
export function vercelBlobStore(options) {
|
|
8
|
+
const prefix = options.prefix.replace(/\/+$/, "");
|
|
9
|
+
const pathname = (key) => (prefix ? `${prefix}/${key}` : key);
|
|
10
|
+
const token = options.token;
|
|
11
|
+
return {
|
|
12
|
+
async read(key) {
|
|
13
|
+
const result = await get(pathname(key), { access: "private", useCache: false, token });
|
|
14
|
+
if (!result || result.statusCode !== 200)
|
|
15
|
+
return null;
|
|
16
|
+
return JSON.parse(await new Response(result.stream).text());
|
|
17
|
+
},
|
|
18
|
+
async write(key, value) {
|
|
19
|
+
await put(pathname(key), JSON.stringify(value), {
|
|
20
|
+
access: "private",
|
|
21
|
+
addRandomSuffix: false,
|
|
22
|
+
allowOverwrite: true,
|
|
23
|
+
contentType: "application/json",
|
|
24
|
+
token,
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
async create(key, value) {
|
|
28
|
+
try {
|
|
29
|
+
await put(pathname(key), JSON.stringify(value), {
|
|
30
|
+
access: "private",
|
|
31
|
+
addRandomSuffix: false,
|
|
32
|
+
allowOverwrite: false,
|
|
33
|
+
contentType: "application/json",
|
|
34
|
+
token,
|
|
35
|
+
});
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error instanceof BlobError && /already exists/i.test(error.message))
|
|
40
|
+
return false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async delete(key) {
|
|
45
|
+
await del(pathname(key), { token });
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vouchers (`/vouchers`) — the bookkeeping side. A voucher carries amounts
|
|
3
|
+
* grouped by tax rate, no articles: a supplier invoice with 48 items at 19 %
|
|
4
|
+
* is one voucher item; the articles live in the attached original.
|
|
5
|
+
*/
|
|
6
|
+
import { type FileUpload, type LexwareClient } from "./client.ts";
|
|
7
|
+
export type VoucherType = "purchaseinvoice" | "purchasecreditnote" | "salesinvoice" | "salescreditnote";
|
|
8
|
+
/**
|
|
9
|
+
* Writable statuses. `unchecked` files the voucher under "Belege zur Prüfung"
|
|
10
|
+
* with several fields optional — the safe state for anything read from a
|
|
11
|
+
* document. `open` demands complete data. Other statuses are Lexware's own.
|
|
12
|
+
*/
|
|
13
|
+
export type VoucherStatus = "unchecked" | "open";
|
|
14
|
+
export type VoucherTaxType = "net" | "gross";
|
|
15
|
+
export interface VoucherItem {
|
|
16
|
+
/** Net or gross, per the voucher's taxType. */
|
|
17
|
+
amount: number;
|
|
18
|
+
taxAmount: number;
|
|
19
|
+
taxRatePercent: number;
|
|
20
|
+
categoryId?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface CreateVoucherOptions {
|
|
23
|
+
type: VoucherType;
|
|
24
|
+
/** For purchase invoices: the supplier's invoice number. */
|
|
25
|
+
voucherNumber: string;
|
|
26
|
+
/** yyyy-MM-dd */
|
|
27
|
+
voucherDate: string;
|
|
28
|
+
/** yyyy-MM-dd */
|
|
29
|
+
dueDate?: string;
|
|
30
|
+
totalGrossAmount: number;
|
|
31
|
+
totalTaxAmount: number;
|
|
32
|
+
taxType: VoucherTaxType;
|
|
33
|
+
voucherStatus: VoucherStatus;
|
|
34
|
+
/** The collective contact ("Sammellieferant") instead of a real one. */
|
|
35
|
+
useCollectiveContact?: boolean;
|
|
36
|
+
/** A real contact; ignored with useCollectiveContact. */
|
|
37
|
+
contactId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The supplier's name — only with the collective contact, where it is the
|
|
40
|
+
* only mark. With a contactId the API answers 406
|
|
41
|
+
* (`custom_contact_name_for_referenced_contact_not_allowed`).
|
|
42
|
+
*/
|
|
43
|
+
contactName?: string;
|
|
44
|
+
/** Free text; feeds Lexware's full-text search. */
|
|
45
|
+
remark?: string;
|
|
46
|
+
voucherItems: readonly VoucherItem[];
|
|
47
|
+
}
|
|
48
|
+
export interface CreatedVoucher {
|
|
49
|
+
id: string;
|
|
50
|
+
resourceUri?: string;
|
|
51
|
+
url: string;
|
|
52
|
+
}
|
|
53
|
+
/** Creates a voucher. `shippingDate` stays unset: the API allows it only for sales vouchers. */
|
|
54
|
+
export declare function createVoucher(client: LexwareClient, options: CreateVoucherOptions, signal?: AbortSignal): Promise<CreatedVoucher>;
|
|
55
|
+
/**
|
|
56
|
+
* Attaches a file (PDF, image, XML) to a voucher. Lexware keeps a checksum:
|
|
57
|
+
* the same file again returns the existing file id, no copy.
|
|
58
|
+
*/
|
|
59
|
+
export declare function uploadVoucherFile(client: LexwareClient, voucherId: string, file: FileUpload, signal?: AbortSignal): Promise<{
|
|
60
|
+
id: string;
|
|
61
|
+
}>;
|
|
62
|
+
export interface VoucherListEntry {
|
|
63
|
+
id: string;
|
|
64
|
+
voucherType: string;
|
|
65
|
+
voucherStatus: string;
|
|
66
|
+
voucherNumber?: string;
|
|
67
|
+
voucherDate?: string;
|
|
68
|
+
dueDate?: string;
|
|
69
|
+
contactId?: string;
|
|
70
|
+
contactName?: string;
|
|
71
|
+
totalAmount?: number;
|
|
72
|
+
openAmount?: number;
|
|
73
|
+
archived?: boolean;
|
|
74
|
+
/** Link into the web app. */
|
|
75
|
+
url: string;
|
|
76
|
+
}
|
|
77
|
+
export interface VoucherListFilter {
|
|
78
|
+
/** Comma-separated types, or `any`. Required by the API. */
|
|
79
|
+
voucherType: string;
|
|
80
|
+
/** Comma-separated statuses, or `any`. Required by the API. */
|
|
81
|
+
voucherStatus: string;
|
|
82
|
+
voucherNumber?: string;
|
|
83
|
+
contactId?: string;
|
|
84
|
+
voucherDateFrom?: string;
|
|
85
|
+
voucherDateTo?: string;
|
|
86
|
+
archived?: boolean;
|
|
87
|
+
size?: number;
|
|
88
|
+
sort?: string;
|
|
89
|
+
}
|
|
90
|
+
/** The voucher list — the one search across all voucher types. Newest first, 50 by default. */
|
|
91
|
+
export declare function findVouchers(client: LexwareClient, filter: VoucherListFilter, signal?: AbortSignal): Promise<VoucherListEntry[]>;
|
|
92
|
+
/**
|
|
93
|
+
* An already captured purchase invoice or credit note with this number, any
|
|
94
|
+
* status. Account-wide, not per vendor: two vendors with the same number
|
|
95
|
+
* match too, so the caller should compare `contactName` and warn rather than
|
|
96
|
+
* refuse.
|
|
97
|
+
*/
|
|
98
|
+
export declare function findPurchaseInvoiceByNumber(client: LexwareClient, voucherNumber: string, signal?: AbortSignal): Promise<VoucherListEntry | null>;
|
|
99
|
+
export interface CategoryHistory {
|
|
100
|
+
/** Distinct categories of the vendor's booked vouchers, most recent first. */
|
|
101
|
+
categories: Array<{
|
|
102
|
+
id: string;
|
|
103
|
+
name: string;
|
|
104
|
+
}>;
|
|
105
|
+
scanned: number;
|
|
106
|
+
/** More booked vouchers existed than were checked. */
|
|
107
|
+
incomplete: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* A single booked voucher was split across categories. Then the distinct
|
|
110
|
+
* list is no either/or choice: the vendor is categorized per position.
|
|
111
|
+
*/
|
|
112
|
+
split: boolean;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The categories this vendor's booked purchase invoices were filed under —
|
|
116
|
+
* the basis for picking one automatically: always the same category means
|
|
117
|
+
* keep it; several mean the document has to decide. Only `open,paid,paidoff`
|
|
118
|
+
* count: an unchecked voucher may carry a wrong category. The voucher list has
|
|
119
|
+
* no categories, so each is fetched in detail — capped at `maxDetails`.
|
|
120
|
+
*/
|
|
121
|
+
export declare function findVendorCategoryHistory(client: LexwareClient, contactId: string, options?: {
|
|
122
|
+
signal?: AbortSignal;
|
|
123
|
+
maxDetails?: number;
|
|
124
|
+
lookbackDays?: number;
|
|
125
|
+
}): Promise<CategoryHistory>;
|
package/dist/vouchers.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vouchers (`/vouchers`) — the bookkeeping side. A voucher carries amounts
|
|
3
|
+
* grouped by tax rate, no articles: a supplier invoice with 48 items at 19 %
|
|
4
|
+
* is one voucher item; the articles live in the attached original.
|
|
5
|
+
*/
|
|
6
|
+
import { listPostingCategories } from "./categories.js";
|
|
7
|
+
import { query } from "./client.js";
|
|
8
|
+
import { round2 } from "./money.js";
|
|
9
|
+
/** Creates a voucher. `shippingDate` stays unset: the API allows it only for sales vouchers. */
|
|
10
|
+
export async function createVoucher(client, options, signal) {
|
|
11
|
+
if (options.voucherStatus === "unchecked" && options.taxType === "net") {
|
|
12
|
+
throw new Error("Ein Beleg mit Status 'unchecked' darf nicht taxType 'net' haben — die API verbietet die Kombination. " +
|
|
13
|
+
"Bruttobeträge übergeben (taxType 'gross') oder den Beleg mit Status 'open' vollständig erfassen.");
|
|
14
|
+
}
|
|
15
|
+
if (!options.useCollectiveContact && !options.contactId) {
|
|
16
|
+
throw new Error("Beleg ohne Kontakt: entweder useCollectiveContact setzen oder eine contactId übergeben.");
|
|
17
|
+
}
|
|
18
|
+
const payload = {
|
|
19
|
+
type: options.type,
|
|
20
|
+
voucherStatus: options.voucherStatus,
|
|
21
|
+
voucherNumber: options.voucherNumber,
|
|
22
|
+
voucherDate: options.voucherDate,
|
|
23
|
+
...(options.dueDate ? { dueDate: options.dueDate } : {}),
|
|
24
|
+
totalGrossAmount: round2(options.totalGrossAmount),
|
|
25
|
+
totalTaxAmount: round2(options.totalTaxAmount),
|
|
26
|
+
taxType: options.taxType,
|
|
27
|
+
...(options.useCollectiveContact
|
|
28
|
+
? { useCollectiveContact: true, ...(options.contactName ? { contactName: options.contactName } : {}) }
|
|
29
|
+
: { useCollectiveContact: false, contactId: options.contactId }),
|
|
30
|
+
...(options.remark ? { remark: options.remark } : {}),
|
|
31
|
+
voucherItems: options.voucherItems.map((item) => ({
|
|
32
|
+
amount: round2(item.amount),
|
|
33
|
+
taxAmount: round2(item.taxAmount),
|
|
34
|
+
taxRatePercent: item.taxRatePercent,
|
|
35
|
+
...(item.categoryId ? { categoryId: item.categoryId } : {}),
|
|
36
|
+
})),
|
|
37
|
+
version: 0,
|
|
38
|
+
};
|
|
39
|
+
const result = await client.request("/vouchers", { method: "POST", json: payload, signal });
|
|
40
|
+
return { id: result.id, resourceUri: result.resourceUri, url: client.voucherUrl(result.id) };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Attaches a file (PDF, image, XML) to a voucher. Lexware keeps a checksum:
|
|
44
|
+
* the same file again returns the existing file id, no copy.
|
|
45
|
+
*/
|
|
46
|
+
export async function uploadVoucherFile(client, voucherId, file, signal) {
|
|
47
|
+
return client.upload(`/vouchers/${encodeURIComponent(voucherId)}/files`, file, { signal });
|
|
48
|
+
}
|
|
49
|
+
/** The voucher list — the one search across all voucher types. Newest first, 50 by default. */
|
|
50
|
+
export async function findVouchers(client, filter, signal) {
|
|
51
|
+
const page = await client.request(`/voucherlist${query({ size: 50, sort: "voucherDate,DESC", ...filter })}`, { signal });
|
|
52
|
+
return (page.content ?? []).map((entry) => ({ ...entry, url: client.voucherUrl(entry.id) }));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* An already captured purchase invoice or credit note with this number, any
|
|
56
|
+
* status. Account-wide, not per vendor: two vendors with the same number
|
|
57
|
+
* match too, so the caller should compare `contactName` and warn rather than
|
|
58
|
+
* refuse.
|
|
59
|
+
*/
|
|
60
|
+
export async function findPurchaseInvoiceByNumber(client, voucherNumber, signal) {
|
|
61
|
+
const matches = await findVouchers(client, { voucherType: "purchaseinvoice,purchasecreditnote", voucherStatus: "any", voucherNumber }, signal);
|
|
62
|
+
return matches[0] ?? null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The categories this vendor's booked purchase invoices were filed under —
|
|
66
|
+
* the basis for picking one automatically: always the same category means
|
|
67
|
+
* keep it; several mean the document has to decide. Only `open,paid,paidoff`
|
|
68
|
+
* count: an unchecked voucher may carry a wrong category. The voucher list has
|
|
69
|
+
* no categories, so each is fetched in detail — capped at `maxDetails`.
|
|
70
|
+
*/
|
|
71
|
+
export async function findVendorCategoryHistory(client, contactId, options = {}) {
|
|
72
|
+
const from = new Date(Date.now() - (options.lookbackDays ?? 400) * 86_400_000).toISOString().slice(0, 10);
|
|
73
|
+
const booked = await findVouchers(client, { voucherType: "purchaseinvoice", voucherStatus: "open,paid,paidoff", contactId, voucherDateFrom: from, size: 20 }, options.signal);
|
|
74
|
+
const checked = booked.slice(0, options.maxDetails ?? 5);
|
|
75
|
+
const ids = [];
|
|
76
|
+
let split = false;
|
|
77
|
+
for (const entry of checked) {
|
|
78
|
+
const detail = await client.request(`/vouchers/${entry.id}`, { signal: options.signal });
|
|
79
|
+
const perVoucher = new Set();
|
|
80
|
+
for (const item of detail.voucherItems ?? []) {
|
|
81
|
+
if (!item.categoryId)
|
|
82
|
+
continue;
|
|
83
|
+
perVoucher.add(item.categoryId);
|
|
84
|
+
if (!ids.includes(item.categoryId))
|
|
85
|
+
ids.push(item.categoryId);
|
|
86
|
+
}
|
|
87
|
+
if (perVoucher.size > 1)
|
|
88
|
+
split = true;
|
|
89
|
+
}
|
|
90
|
+
const known = await listPostingCategories(client, options.signal);
|
|
91
|
+
return {
|
|
92
|
+
// An id the list does not know stays visible as the id.
|
|
93
|
+
categories: ids.map((id) => ({ id, name: known.find((category) => category.id === id)?.name ?? id })),
|
|
94
|
+
scanned: checked.length,
|
|
95
|
+
incomplete: booked.length > checked.length,
|
|
96
|
+
split,
|
|
97
|
+
};
|
|
98
|
+
}
|