@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/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { DEFAULT_APP_URL, DEFAULT_BASE_URL, LexwareApiError, createLexwareClient, describeError, query, } from "./client.js";
2
+ export { contactEmail, createCustomer, findContactByNumber, findContactsByName, getContact, resolveContactByName, } from "./contacts.js";
3
+ export { addDays, berlinDay, daysSince } from "./dates.js";
4
+ export { downloadInvoicePdf, findOverdueInvoice, findOverdueInvoices, } from "./receivables.js";
5
+ export { DEFAULT_REMINDER_POLICY, daysPastReminderDue, earliestReminderDate, isDueImmediately, isOpenForReminder, reminderDueDate, reminderLedger, selectDueReminders, sendReminder, } from "./reminders.js";
6
+ export { MailNotSentError, defaultReminderMail, reminderSubject, } from "./reminder-mail.js";
7
+ export { keySegment, memoryStore } from "./store.js";
8
+ export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, netTotal, round2, taxFromGross, taxFromNet, } from "./money.js";
9
+ export { REVERSE_CHARGE_TAX_RATE, isReverseChargeCategory, listPostingCategories, resolveCategory, } from "./categories.js";
10
+ export { createVoucher, findPurchaseInvoiceByNumber, findVendorCategoryHistory, findVouchers, uploadVoucherFile, } from "./vouchers.js";
11
+ export { SALES_DOCUMENT_STATUSES, VOUCHERLIST_TYPE, createSalesDocument, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPursueRejection, } from "./sales-documents.js";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Amounts, taxes, and tolerances — one source, so a check and the card that
3
+ * shows it can never disagree about "matches".
4
+ */
5
+ /**
6
+ * Tolerance when reconciling line items with a total read off a document.
7
+ * Covers the rounding of the document, nothing more.
8
+ */
9
+ export declare const ITEMS_TOTAL_TOLERANCE = 0.02;
10
+ /**
11
+ * Tolerance when comparing two already-rounded line-item totals, as the
12
+ * duplicate searches do. The same size as ITEMS_TOTAL_TOLERANCE, but a
13
+ * different question — hence its own name.
14
+ */
15
+ export declare const DUPLICATE_TOTAL_TOLERANCE = 0.02;
16
+ export declare function round2(value: number): number;
17
+ /** A priced line, as far as totals need it. */
18
+ export interface PricedLine {
19
+ quantity: number;
20
+ netPrice: number;
21
+ }
22
+ /** Net total of line items, rounded to the cent. */
23
+ export declare function netTotal(lines: readonly PricedLine[]): number;
24
+ /** Tax contained in a gross amount. */
25
+ export declare function taxFromGross(gross: number, taxRatePercent: number): number;
26
+ /** Tax on a net amount. */
27
+ export declare function taxFromNet(net: number, taxRatePercent: number): number;
package/dist/money.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Amounts, taxes, and tolerances — one source, so a check and the card that
3
+ * shows it can never disagree about "matches".
4
+ */
5
+ /**
6
+ * Tolerance when reconciling line items with a total read off a document.
7
+ * Covers the rounding of the document, nothing more.
8
+ */
9
+ export const ITEMS_TOTAL_TOLERANCE = 0.02;
10
+ /**
11
+ * Tolerance when comparing two already-rounded line-item totals, as the
12
+ * duplicate searches do. The same size as ITEMS_TOTAL_TOLERANCE, but a
13
+ * different question — hence its own name.
14
+ */
15
+ export const DUPLICATE_TOTAL_TOLERANCE = 0.02;
16
+ export function round2(value) {
17
+ return Math.round((value + Number.EPSILON) * 100) / 100;
18
+ }
19
+ /** Net total of line items, rounded to the cent. */
20
+ export function netTotal(lines) {
21
+ return round2(lines.reduce((sum, line) => sum + line.quantity * line.netPrice, 0));
22
+ }
23
+ /** Tax contained in a gross amount. */
24
+ export function taxFromGross(gross, taxRatePercent) {
25
+ return round2(gross - gross / (1 + taxRatePercent / 100));
26
+ }
27
+ /** Tax on a net amount. */
28
+ export function taxFromNet(net, taxRatePercent) {
29
+ return round2(net * (taxRatePercent / 100));
30
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Open receivables: overdue invoices and their PDF.
3
+ *
4
+ * The voucher list carries what a reminder needs — due date, open amount,
5
+ * customer — so choosing which invoice to remind is a list call and date
6
+ * arithmetic, not a model decision.
7
+ */
8
+ import { type LexwareClient } from "./client.ts";
9
+ export interface OverdueInvoice {
10
+ id: string;
11
+ voucherNumber: string;
12
+ /** yyyy-MM-dd */
13
+ voucherDate: string;
14
+ /** yyyy-MM-dd. Equal to the voucher date for an invoice due immediately. */
15
+ dueDate: string;
16
+ contactId?: string;
17
+ contactName: string;
18
+ totalAmount: number;
19
+ openAmount: number;
20
+ currency: string;
21
+ }
22
+ /**
23
+ * Every invoice Lexware Office counts as overdue. `overdue` has to stand alone
24
+ * in the status filter — the API rejects it combined with other statuses.
25
+ */
26
+ export declare function findOverdueInvoices(client: LexwareClient, options?: {
27
+ signal?: AbortSignal;
28
+ maxPages?: number;
29
+ }): Promise<OverdueInvoice[]>;
30
+ /** One overdue invoice, or null once it no longer is (paid, voided). */
31
+ export declare function findOverdueInvoice(client: LexwareClient, id: string, signal?: AbortSignal): Promise<OverdueInvoice | null>;
32
+ /** An invoice as Lexware Office renders it — the PDF the customer received. */
33
+ export declare function downloadInvoicePdf(client: LexwareClient, invoice: {
34
+ id: string;
35
+ voucherNumber: string;
36
+ }, signal?: AbortSignal): Promise<{
37
+ bytes: Uint8Array;
38
+ filename: string;
39
+ mediaType: "application/pdf";
40
+ }>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Open receivables: overdue invoices and their PDF.
3
+ *
4
+ * The voucher list carries what a reminder needs — due date, open amount,
5
+ * customer — so choosing which invoice to remind is a list call and date
6
+ * arithmetic, not a model decision.
7
+ */
8
+ import { query } from "./client.js";
9
+ /**
10
+ * Every invoice Lexware Office counts as overdue. `overdue` has to stand alone
11
+ * in the status filter — the API rejects it combined with other statuses.
12
+ */
13
+ export async function findOverdueInvoices(client, options = {}) {
14
+ const invoices = [];
15
+ for (let page = 0; page < (options.maxPages ?? 20); page++) {
16
+ const result = await client.request(`/voucherlist${query({ voucherType: "invoice", voucherStatus: "overdue", size: 100, page, sort: "voucherDate,ASC" })}`, { signal: options.signal });
17
+ for (const entry of result.content ?? []) {
18
+ if (!entry.voucherNumber || !entry.dueDate)
19
+ continue;
20
+ invoices.push({
21
+ id: entry.id,
22
+ voucherNumber: entry.voucherNumber,
23
+ voucherDate: entry.voucherDate?.slice(0, 10) ?? "",
24
+ dueDate: entry.dueDate.slice(0, 10),
25
+ contactId: entry.contactId,
26
+ contactName: entry.contactName?.trim() || "unbekannter Kunde",
27
+ totalAmount: entry.totalAmount ?? 0,
28
+ openAmount: entry.openAmount ?? entry.totalAmount ?? 0,
29
+ currency: entry.currency ?? "EUR",
30
+ });
31
+ }
32
+ if (result.last !== false || (result.content ?? []).length === 0)
33
+ break;
34
+ }
35
+ return invoices;
36
+ }
37
+ /** One overdue invoice, or null once it no longer is (paid, voided). */
38
+ export async function findOverdueInvoice(client, id, signal) {
39
+ return (await findOverdueInvoices(client, { signal })).find((invoice) => invoice.id === id) ?? null;
40
+ }
41
+ /** An invoice as Lexware Office renders it — the PDF the customer received. */
42
+ export async function downloadInvoicePdf(client, invoice, signal) {
43
+ const file = await client.download(`/invoices/${encodeURIComponent(invoice.id)}/file`, { accept: "application/pdf", signal });
44
+ return { bytes: file.bytes, filename: `${invoice.voucherNumber}.pdf`, mediaType: "application/pdf" };
45
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The payment reminder as a mail: what is sent, how it is rendered, and the
3
+ * default text — a friendly first reminder that assumes an oversight.
4
+ *
5
+ * Bring your own look with `render`; the default is plain, inline-styled
6
+ * HTML that every mail client shows. Sender details (name, address, legal
7
+ * notes) are yours to pass: a business letter needs them.
8
+ */
9
+ export interface ReminderMailData {
10
+ voucherNumber: string;
11
+ /** yyyy-MM-dd */
12
+ voucherDate: string;
13
+ /** yyyy-MM-dd */
14
+ dueDate: string;
15
+ openAmount: number;
16
+ currency: string;
17
+ /** Set in test mode: whom the mail would reach live. */
18
+ testRecipientFor?: string;
19
+ }
20
+ export interface ReminderMail {
21
+ to: string;
22
+ cc: readonly string[];
23
+ subject: string;
24
+ html: string;
25
+ text?: string;
26
+ attachments: readonly {
27
+ filename: string;
28
+ mediaType: string;
29
+ bytes: Uint8Array;
30
+ }[];
31
+ }
32
+ /** Sends one mail. Throw MailNotSentError when it certainly did not go out. */
33
+ export type SendReminderMail = (mail: ReminderMail, signal?: AbortSignal) => Promise<void>;
34
+ /**
35
+ * The mail certainly did not leave: sign-in failed, the mail service refused
36
+ * it. Anything else thrown while sending counts as "may have left" — the send
37
+ * round then stays taken, since a second reminder is worse than none.
38
+ */
39
+ export declare class MailNotSentError extends Error {
40
+ constructor(message: string, options?: {
41
+ cause?: unknown;
42
+ });
43
+ }
44
+ export interface ReminderSender {
45
+ /** Signature name, e.g. "Kevin Ludwig". */
46
+ name: string;
47
+ /** Lines under the name, e.g. the company and town. */
48
+ signature?: readonly string[];
49
+ /** Legal details below the mail: name, form, address, VAT id, links. */
50
+ legal?: readonly string[];
51
+ }
52
+ export type RenderReminderMail = (data: ReminderMailData) => {
53
+ subject: string;
54
+ html: string;
55
+ text?: string;
56
+ } | Promise<{
57
+ subject: string;
58
+ html: string;
59
+ text?: string;
60
+ }>;
61
+ export declare function reminderSubject(voucherNumber: string, test: boolean): string;
62
+ /** The default reminder: German, friendly, marked as sent automatically. */
63
+ export declare function defaultReminderMail(sender: ReminderSender): RenderReminderMail;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The payment reminder as a mail: what is sent, how it is rendered, and the
3
+ * default text — a friendly first reminder that assumes an oversight.
4
+ *
5
+ * Bring your own look with `render`; the default is plain, inline-styled
6
+ * HTML that every mail client shows. Sender details (name, address, legal
7
+ * notes) are yours to pass: a business letter needs them.
8
+ */
9
+ /**
10
+ * The mail certainly did not leave: sign-in failed, the mail service refused
11
+ * it. Anything else thrown while sending counts as "may have left" — the send
12
+ * round then stays taken, since a second reminder is worse than none.
13
+ */
14
+ export class MailNotSentError extends Error {
15
+ constructor(message, options) {
16
+ super(message, options);
17
+ this.name = "MailNotSentError";
18
+ }
19
+ }
20
+ const escape = (value) => value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
21
+ const longDate = (day) => new Date(`${day.slice(0, 10)}T12:00:00Z`).toLocaleDateString("de-DE", {
22
+ day: "numeric",
23
+ month: "long",
24
+ year: "numeric",
25
+ timeZone: "Europe/Berlin",
26
+ });
27
+ const money = (value, currency) => value.toLocaleString("de-DE", { style: "currency", currency });
28
+ export function reminderSubject(voucherNumber, test) {
29
+ return `${test ? "[Test] " : ""}Zahlungserinnerung zur Rechnung ${voucherNumber}`;
30
+ }
31
+ /** The default reminder: German, friendly, marked as sent automatically. */
32
+ export function defaultReminderMail(sender) {
33
+ return (data) => {
34
+ const amount = money(data.openAmount, data.currency);
35
+ const lines = [
36
+ "Guten Tag,",
37
+ "",
38
+ `vielleicht ist es im Alltag untergegangen: Für unsere Rechnung ${data.voucherNumber} vom ${longDate(data.voucherDate)} ` +
39
+ `haben wir noch keinen Zahlungseingang gesehen. Sie war am ${longDate(data.dueDate)} fällig.`,
40
+ "",
41
+ "Die Rechnung hängt noch einmal an. Wir freuen uns, wenn Sie den offenen Betrag in den nächsten Tagen " +
42
+ "überweisen; die Bankverbindung steht auf der Rechnung.",
43
+ "",
44
+ `Rechnung: ${data.voucherNumber}`,
45
+ `Rechnungsdatum: ${longDate(data.voucherDate)}`,
46
+ `Fällig seit: ${longDate(data.dueDate)}`,
47
+ `Offener Betrag: ${amount}`,
48
+ "",
49
+ "Hat sich Ihre Zahlung mit dieser Nachricht überschnitten, betrachten Sie sie bitte als erledigt.",
50
+ "",
51
+ "Mit besten Grüßen",
52
+ sender.name,
53
+ ...(sender.signature ?? []),
54
+ ];
55
+ const footer = [
56
+ "Diese Zahlungserinnerung wurde automatisch erstellt und versendet.",
57
+ ...(sender.legal ?? []),
58
+ ];
59
+ const paragraph = (text) => `<p style="margin:0 0 12px">${escape(text)}</p>`;
60
+ const html = [
61
+ `<div style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;color:#1a1a1a;max-width:600px">`,
62
+ data.testRecipientFor
63
+ ? `<p style="margin:0 0 16px;padding:8px 12px;background:#fff1c2">Testversand. Im Echtbetrieb ginge diese Mail an ${escape(data.testRecipientFor)}.</p>`
64
+ : "",
65
+ lines
66
+ .join("\n")
67
+ .split("\n\n")
68
+ .map((block) => paragraph(block).replaceAll("\n", "<br>"))
69
+ .join(""),
70
+ `<p style="margin:24px 0 0;font-size:12px;color:#666">${footer.map(escape).join("<br>")}</p>`,
71
+ "</div>",
72
+ ].join("");
73
+ const text = [...(data.testRecipientFor ? [`[Testversand, im Echtbetrieb an ${data.testRecipientFor}]`, ""] : []), ...lines, "", "--", ...footer].join("\n");
74
+ return { subject: reminderSubject(data.voucherNumber, Boolean(data.testRecipientFor)), html, text };
75
+ };
76
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Payment reminders: when an overdue invoice is due for one, and what has
3
+ * happened per invoice.
4
+ *
5
+ * The selection is code, not judgment: an invoice is offered once it is a
6
+ * number of days past its reminder due date, has not been reminded, is not
7
+ * waiting for a decision elsewhere, was not declined lately, and its customer
8
+ * has an address. An invoice due immediately ("sofort fällig" — Lexware Office
9
+ * sets the due date to the invoice date) gets a payment term first.
10
+ *
11
+ * The ledger keeps one record per invoice, and one claim per send round: a
12
+ * round is the first reminder, or a deliberate repeat after the reminder sent
13
+ * at a given time. Claiming before sending makes "at most once per round"
14
+ * hold across cards, duplicate schedule runs, and replays.
15
+ */
16
+ import type { LexwareClient } from "./client.ts";
17
+ import { type OverdueInvoice } from "./receivables.ts";
18
+ import { type RenderReminderMail, type SendReminderMail } from "./reminder-mail.ts";
19
+ import { type JsonStore } from "./store.ts";
20
+ export interface ReminderPolicy {
21
+ /** Days past the reminder due date before a first reminder. Default: 3. */
22
+ minDaysOverdue: number;
23
+ /** Payment term assumed for an invoice due immediately, in days. Default: 14. */
24
+ immediatePaymentTermDays: number;
25
+ /** A waiting reminder is offered again after this many hours. Default: 20. */
26
+ pendingValidHours: number;
27
+ /** A declined reminder stays quiet this many days. Default: 7. */
28
+ declinedQuietDays: number;
29
+ }
30
+ export declare const DEFAULT_REMINDER_POLICY: ReminderPolicy;
31
+ interface Dated {
32
+ voucherDate: string;
33
+ dueDate: string;
34
+ }
35
+ /** Whether the due date is the invoice date itself. */
36
+ export declare function isDueImmediately(invoice: Dated): boolean;
37
+ /** The day from which lateness counts (yyyy-MM-dd). */
38
+ export declare function reminderDueDate(invoice: Dated, policy?: ReminderPolicy): string;
39
+ /** Days past the reminder due date. */
40
+ export declare function daysPastReminderDue(invoice: Dated, policy?: ReminderPolicy, today?: Date): number;
41
+ /** The first day a reminder is offered (yyyy-MM-dd). */
42
+ export declare function earliestReminderDate(invoice: Dated, policy?: ReminderPolicy): string;
43
+ export type ReminderStatus = "pending" | "sent" | "declined";
44
+ export interface ReminderRecord {
45
+ invoiceId: string;
46
+ voucherNumber: string;
47
+ status: ReminderStatus;
48
+ /** ISO timestamp of the last change. */
49
+ at: string;
50
+ /** Where the reminder waits or was decided, e.g. an agent session. */
51
+ sessionId?: string;
52
+ recipient?: string;
53
+ }
54
+ /** Whether a record still lets a reminder be offered. */
55
+ export declare function isOpenForReminder(record: ReminderRecord | null, policy?: ReminderPolicy, now?: number): boolean;
56
+ /** One record per invoice and one claim per send round, in a JsonStore. */
57
+ export interface ReminderLedger {
58
+ /** The record, or null. Throws when the store fails: unknown is not "never reminded". */
59
+ read(invoiceId: string): Promise<ReminderRecord | null>;
60
+ write(record: Omit<ReminderRecord, "at">): Promise<void>;
61
+ /** Marks the invoice as waiting — unless it was sent: "sent" is final. */
62
+ markPending(invoice: {
63
+ id: string;
64
+ voucherNumber: string;
65
+ }, sessionId?: string): Promise<void>;
66
+ /**
67
+ * Takes the one send of a round before the mail goes out. `round` is
68
+ * "first", or the time of the reminder a deliberate repeat follows. False
69
+ * when another send holds the round.
70
+ */
71
+ claimSend(invoiceId: string, round: string, owner: string): Promise<boolean>;
72
+ /** Gives a round back when its mail certainly did not go out. */
73
+ releaseSend(invoiceId: string, round: string): Promise<void>;
74
+ }
75
+ /**
76
+ * A ledger under `namespace` in the store. Keep separate namespaces for test
77
+ * and live sending, and per environment, so a test never stands in for a real
78
+ * reminder.
79
+ */
80
+ export declare function reminderLedger(store: JsonStore, namespace: string): ReminderLedger;
81
+ /** Why an overdue invoice is not offered now. */
82
+ export type ReminderHold = {
83
+ kind: "too-early";
84
+ earliest: string;
85
+ immediate: boolean;
86
+ } | {
87
+ kind: "sent" | "pending" | "declined";
88
+ at: string;
89
+ sessionId?: string;
90
+ } | {
91
+ kind: "no-address";
92
+ } | {
93
+ kind: "unreadable";
94
+ reason: string;
95
+ };
96
+ export interface DueReminder {
97
+ invoice: OverdueInvoice;
98
+ /** The customer's address as Lexware Office has it. */
99
+ customerEmail: string;
100
+ }
101
+ export interface ReminderSelection {
102
+ due: DueReminder[];
103
+ held: {
104
+ invoice: OverdueInvoice;
105
+ hold: ReminderHold;
106
+ }[];
107
+ }
108
+ /**
109
+ * Which overdue invoices get a reminder now. An invoice whose record or
110
+ * address cannot be read is held back, never offered as "never reminded".
111
+ */
112
+ export declare function selectDueReminders(client: LexwareClient, ledger: ReminderLedger, options?: {
113
+ policy?: Partial<ReminderPolicy>;
114
+ signal?: AbortSignal;
115
+ today?: Date;
116
+ }): Promise<ReminderSelection>;
117
+ /** Everything a send needs, fixed when the reminder was decided on. */
118
+ export interface ReminderTarget {
119
+ invoiceId: string;
120
+ voucherNumber: string;
121
+ voucherDate: string;
122
+ dueDate: string;
123
+ currency: string;
124
+ /** The recipient — the customer live, someone else in test mode. */
125
+ to: string;
126
+ cc?: readonly string[];
127
+ test: boolean;
128
+ /** The customer's address, for the test banner. */
129
+ customer?: string | null;
130
+ /** When a deliberate repeat follows an earlier reminder: that reminder's time — its send round. */
131
+ earlierSentAt?: string;
132
+ }
133
+ export type SendReminderOutcome = {
134
+ sent: true;
135
+ openAmount: number;
136
+ attachment: string;
137
+ } | {
138
+ sent: false;
139
+ reason: "paid" | "taken";
140
+ };
141
+ /**
142
+ * Sends one reminder, at most once per round: re-checks that the invoice is
143
+ * still overdue, claims the round, renders and sends with the invoice PDF,
144
+ * and records it. Up to the mail call nothing has left, so a failure there
145
+ * frees the round; so does MailNotSentError. Any other failure while sending
146
+ * keeps the round taken.
147
+ */
148
+ export declare function sendReminder(client: LexwareClient, ledger: ReminderLedger, target: ReminderTarget, options: {
149
+ render: RenderReminderMail;
150
+ sendMail: SendReminderMail;
151
+ /** Who sends — recorded with the claim, e.g. a tool call id. */
152
+ owner: string;
153
+ sessionId?: string;
154
+ signal?: AbortSignal;
155
+ }): Promise<SendReminderOutcome>;
156
+ export {};
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Payment reminders: when an overdue invoice is due for one, and what has
3
+ * happened per invoice.
4
+ *
5
+ * The selection is code, not judgment: an invoice is offered once it is a
6
+ * number of days past its reminder due date, has not been reminded, is not
7
+ * waiting for a decision elsewhere, was not declined lately, and its customer
8
+ * has an address. An invoice due immediately ("sofort fällig" — Lexware Office
9
+ * sets the due date to the invoice date) gets a payment term first.
10
+ *
11
+ * The ledger keeps one record per invoice, and one claim per send round: a
12
+ * round is the first reminder, or a deliberate repeat after the reminder sent
13
+ * at a given time. Claiming before sending makes "at most once per round"
14
+ * hold across cards, duplicate schedule runs, and replays.
15
+ */
16
+ import { contactEmail } from "./contacts.js";
17
+ import { addDays, daysSince } from "./dates.js";
18
+ import { downloadInvoicePdf, findOverdueInvoice, findOverdueInvoices } from "./receivables.js";
19
+ import { MailNotSentError } from "./reminder-mail.js";
20
+ import { keySegment } from "./store.js";
21
+ export const DEFAULT_REMINDER_POLICY = {
22
+ minDaysOverdue: 3,
23
+ immediatePaymentTermDays: 14,
24
+ pendingValidHours: 20,
25
+ declinedQuietDays: 7,
26
+ };
27
+ /** Whether the due date is the invoice date itself. */
28
+ export function isDueImmediately(invoice) {
29
+ return invoice.voucherDate !== "" && invoice.dueDate.slice(0, 10) <= invoice.voucherDate.slice(0, 10);
30
+ }
31
+ /** The day from which lateness counts (yyyy-MM-dd). */
32
+ export function reminderDueDate(invoice, policy = DEFAULT_REMINDER_POLICY) {
33
+ return isDueImmediately(invoice)
34
+ ? addDays(invoice.voucherDate, policy.immediatePaymentTermDays)
35
+ : invoice.dueDate.slice(0, 10);
36
+ }
37
+ /** Days past the reminder due date. */
38
+ export function daysPastReminderDue(invoice, policy = DEFAULT_REMINDER_POLICY, today) {
39
+ return daysSince(reminderDueDate(invoice, policy), today);
40
+ }
41
+ /** The first day a reminder is offered (yyyy-MM-dd). */
42
+ export function earliestReminderDate(invoice, policy = DEFAULT_REMINDER_POLICY) {
43
+ return addDays(reminderDueDate(invoice, policy), policy.minDaysOverdue);
44
+ }
45
+ /** Whether a record still lets a reminder be offered. */
46
+ export function isOpenForReminder(record, policy = DEFAULT_REMINDER_POLICY, now = Date.now()) {
47
+ if (!record)
48
+ return true;
49
+ const age = now - Date.parse(record.at);
50
+ switch (record.status) {
51
+ case "sent":
52
+ return false;
53
+ case "pending":
54
+ return age > policy.pendingValidHours * 3_600_000;
55
+ case "declined":
56
+ return age > policy.declinedQuietDays * 86_400_000;
57
+ }
58
+ }
59
+ /**
60
+ * A ledger under `namespace` in the store. Keep separate namespaces for test
61
+ * and live sending, and per environment, so a test never stands in for a real
62
+ * reminder.
63
+ */
64
+ export function reminderLedger(store, namespace) {
65
+ const base = namespace.replace(/\/+$/, "");
66
+ const recordKey = (invoiceId) => `${base}/${keySegment(invoiceId)}`;
67
+ const claimKey = (invoiceId, round) => `${recordKey(invoiceId)}.send-${keySegment(round)}`;
68
+ return {
69
+ read: (invoiceId) => store.read(recordKey(invoiceId)),
70
+ write: (record) => store.write(recordKey(record.invoiceId), { ...record, at: new Date().toISOString() }),
71
+ async markPending(invoice, sessionId) {
72
+ if ((await store.read(recordKey(invoice.id)))?.status === "sent")
73
+ return;
74
+ await store.write(recordKey(invoice.id), {
75
+ invoiceId: invoice.id,
76
+ voucherNumber: invoice.voucherNumber,
77
+ status: "pending",
78
+ at: new Date().toISOString(),
79
+ ...(sessionId ? { sessionId } : {}),
80
+ });
81
+ },
82
+ claimSend: (invoiceId, round, owner) => store.create(claimKey(invoiceId, round), { owner, at: new Date().toISOString() }),
83
+ releaseSend: (invoiceId, round) => store.delete(claimKey(invoiceId, round)),
84
+ };
85
+ }
86
+ /**
87
+ * Which overdue invoices get a reminder now. An invoice whose record or
88
+ * address cannot be read is held back, never offered as "never reminded".
89
+ */
90
+ export async function selectDueReminders(client, ledger, options = {}) {
91
+ const policy = { ...DEFAULT_REMINDER_POLICY, ...options.policy };
92
+ const overdue = await findOverdueInvoices(client, { signal: options.signal });
93
+ const now = (options.today ?? new Date()).getTime();
94
+ const checked = await Promise.all(overdue.map(async (invoice) => {
95
+ if (daysPastReminderDue(invoice, policy, options.today) < policy.minDaysOverdue) {
96
+ return {
97
+ invoice,
98
+ hold: { kind: "too-early", earliest: earliestReminderDate(invoice, policy), immediate: isDueImmediately(invoice) },
99
+ };
100
+ }
101
+ let record;
102
+ try {
103
+ record = await ledger.read(invoice.id);
104
+ }
105
+ catch (error) {
106
+ return { invoice, hold: { kind: "unreadable", reason: String(error) } };
107
+ }
108
+ if (record && !isOpenForReminder(record, policy, now)) {
109
+ return { invoice, hold: { kind: record.status, at: record.at, sessionId: record.sessionId } };
110
+ }
111
+ try {
112
+ const email = invoice.contactId ? await contactEmail(client, invoice.contactId, options.signal) : null;
113
+ return email ? { invoice, hold: null, email } : { invoice, hold: { kind: "no-address" } };
114
+ }
115
+ catch (error) {
116
+ return { invoice, hold: { kind: "unreadable", reason: String(error) } };
117
+ }
118
+ }));
119
+ return {
120
+ due: checked.flatMap(({ invoice, hold, email }) => (hold === null && email ? [{ invoice, customerEmail: email }] : [])),
121
+ held: checked.flatMap(({ invoice, hold }) => (hold ? [{ invoice, hold }] : [])),
122
+ };
123
+ }
124
+ /**
125
+ * Sends one reminder, at most once per round: re-checks that the invoice is
126
+ * still overdue, claims the round, renders and sends with the invoice PDF,
127
+ * and records it. Up to the mail call nothing has left, so a failure there
128
+ * frees the round; so does MailNotSentError. Any other failure while sending
129
+ * keeps the round taken.
130
+ */
131
+ export async function sendReminder(client, ledger, target, options) {
132
+ const still = await findOverdueInvoice(client, target.invoiceId, options.signal);
133
+ if (!still)
134
+ return { sent: false, reason: "paid" };
135
+ const round = target.earlierSentAt ?? "first";
136
+ if (!(await ledger.claimSend(target.invoiceId, round, options.owner)))
137
+ return { sent: false, reason: "taken" };
138
+ const release = () => ledger.releaseSend(target.invoiceId, round).catch(() => { });
139
+ let mail;
140
+ try {
141
+ const pdf = await downloadInvoicePdf(client, { id: target.invoiceId, voucherNumber: target.voucherNumber }, options.signal);
142
+ const rendered = await options.render({
143
+ voucherNumber: target.voucherNumber,
144
+ voucherDate: target.voucherDate,
145
+ dueDate: target.dueDate,
146
+ openAmount: still.openAmount,
147
+ currency: target.currency,
148
+ testRecipientFor: target.test ? (target.customer ?? "den Kunden (keine Adresse hinterlegt)") : undefined,
149
+ });
150
+ mail = { to: target.to, cc: target.cc ?? [], ...rendered, attachments: [pdf] };
151
+ }
152
+ catch (error) {
153
+ await release();
154
+ throw error;
155
+ }
156
+ try {
157
+ await options.sendMail(mail, options.signal);
158
+ }
159
+ catch (error) {
160
+ if (error instanceof MailNotSentError)
161
+ await release();
162
+ throw error;
163
+ }
164
+ // The mail is out; the claim alone already keeps a second one away.
165
+ await ledger
166
+ .write({
167
+ invoiceId: target.invoiceId,
168
+ voucherNumber: target.voucherNumber,
169
+ status: "sent",
170
+ recipient: target.to,
171
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
172
+ })
173
+ .catch(() => { });
174
+ return { sent: true, openAmount: still.openAmount, attachment: mail.attachments[0].filename };
175
+ }