@kevludwig/lexware-office 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@ A failed request throws `LexwareApiError` with `status` and `responseBody`; `des
36
36
 
37
37
  **Contacts** — `getContact`, `findContactByNumber`, `findContactsByName`, `resolveContactByName` (exact name first, then the single candidate), `contactEmail`, `createCustomer`.
38
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.
39
+ **Sales documents** — `createSalesDocument` for quotations, order confirmations, and invoices, with `finalize`, a total discount, `precedingSalesVoucherId` for a document chain, `introduction` and `remark`, and line items that are priced (with an optional `description`; `optional` on quotations) or `type: "text"` headings; `SALES_TEXT_LIMITS` holds the API's text lengths; `getSalesDocument` (the lines that count, optional lines apart, totals, discounts, customer), `invoiceExists`, `findRecentSalesDocuments`, `findDuplicateSalesDocument` (same net total, same customer, within a window), `isPursueRejection` for the 406 an unusable chain answers with.
40
40
 
41
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
42
 
package/dist/index.d.ts CHANGED
@@ -2,10 +2,10 @@ export { DEFAULT_APP_URL, DEFAULT_BASE_URL, LexwareApiError, createLexwareClient
2
2
  export { contactEmail, createCustomer, findContactByNumber, findContactsByName, MIN_NAME_SEARCH_LENGTH, getContact, resolveContactByName, type Contact, type CreatedCustomer, type NewCustomer, } from "./contacts.ts";
3
3
  export { addDays, berlinDay, daysSince } from "./dates.ts";
4
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, mailContentSha256, sendReminder, type ReminderTarget, type SendReminderOutcome, } from "./reminders.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, mailContentSha256, paymentReminderEffect, sendReminder, type PaymentReminderEffect, type ReminderTarget, type SendReminderOutcome, } from "./reminders.ts";
6
6
  export { MailNotSentError, defaultReminderMail, reminderSubject, type ReminderMail, type ReminderMailData, type ReminderSender, type RenderReminderMail, type SendReminderMail, } from "./reminder-mail.ts";
7
7
  export { keySegment, memoryStore, type JsonStore } from "./store.ts";
8
8
  export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, netTotal, round2, taxFromGross, taxFromNet, type PricedLine, } from "./money.ts";
9
9
  export { REVERSE_CHARGE_TAX_RATE, isReverseChargeCategory, listPostingCategories, resolveCategory, type PostingCategory, } from "./categories.ts";
10
10
  export { createVoucher, findPurchaseInvoiceByNumber, findVendorCategoryHistory, findVouchers, uploadVoucherFile, voucherFileIds, 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";
11
+ export { SALES_DOCUMENT_STATUSES, VOUCHERLIST_TYPE, countsToTotal, createSalesDocument, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPricedLine, isPursueRejection, SALES_TEXT_LIMITS, type CreatedSalesDocument, type DuplicateSearchResult, type LineItem, type PricedLineItem, type SalesDocumentDetail, type SalesDocumentKind, type SalesListEntry, type TextLineItem, } from "./sales-documents.ts";
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@ export { DEFAULT_APP_URL, DEFAULT_BASE_URL, LexwareApiError, createLexwareClient
2
2
  export { contactEmail, createCustomer, findContactByNumber, findContactsByName, MIN_NAME_SEARCH_LENGTH, getContact, resolveContactByName, } from "./contacts.js";
3
3
  export { addDays, berlinDay, daysSince } from "./dates.js";
4
4
  export { downloadInvoicePdf, findOverdueInvoice, findOverdueInvoices, } from "./receivables.js";
5
- export { DEFAULT_REMINDER_POLICY, daysPastReminderDue, earliestReminderDate, isDueImmediately, isOpenForReminder, reminderDueDate, reminderLedger, selectDueReminders, mailContentSha256, sendReminder, } from "./reminders.js";
5
+ export { DEFAULT_REMINDER_POLICY, daysPastReminderDue, earliestReminderDate, isDueImmediately, isOpenForReminder, reminderDueDate, reminderLedger, selectDueReminders, mailContentSha256, paymentReminderEffect, sendReminder, } from "./reminders.js";
6
6
  export { MailNotSentError, defaultReminderMail, reminderSubject, } from "./reminder-mail.js";
7
7
  export { keySegment, memoryStore } from "./store.js";
8
8
  export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, netTotal, round2, taxFromGross, taxFromNet, } from "./money.js";
9
9
  export { REVERSE_CHARGE_TAX_RATE, isReverseChargeCategory, listPostingCategories, resolveCategory, } from "./categories.js";
10
10
  export { createVoucher, findPurchaseInvoiceByNumber, findVendorCategoryHistory, findVouchers, uploadVoucherFile, voucherFileIds, } from "./vouchers.js";
11
- export { SALES_DOCUMENT_STATUSES, VOUCHERLIST_TYPE, createSalesDocument, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPursueRejection, } from "./sales-documents.js";
11
+ export { SALES_DOCUMENT_STATUSES, VOUCHERLIST_TYPE, countsToTotal, createSalesDocument, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPricedLine, isPursueRejection, SALES_TEXT_LIMITS, } from "./sales-documents.js";
@@ -140,6 +140,34 @@ export type SendReminderOutcome = {
140
140
  sent: false;
141
141
  reason: "paid" | "taken";
142
142
  };
143
+ /**
144
+ * What a sent payment reminder did, in a fixed shape an app's protocol keeps
145
+ * as it is. `amount` and `contentSha256` are null only when the effect is
146
+ * reconstructed after the fact and they are no longer known — never guessed.
147
+ */
148
+ export interface PaymentReminderEffect {
149
+ kind: "payment_reminder";
150
+ to: string;
151
+ cc: string[];
152
+ test: boolean;
153
+ /** The invoice's voucher number. */
154
+ invoice: string;
155
+ invoiceId: string;
156
+ /** The open amount the mail stated. */
157
+ amount: number | null;
158
+ currency: string;
159
+ /** sha256 of the mail's content, see mailContentSha256. */
160
+ contentSha256: string | null;
161
+ }
162
+ /**
163
+ * The effect of a reminder sent to `target`. Pass the send's outcome; pass
164
+ * null to reconstruct it from the target alone, which leaves amount and hash
165
+ * unknown (null).
166
+ */
167
+ export declare function paymentReminderEffect(target: ReminderTarget, sent: {
168
+ openAmount: number;
169
+ contentSha256: string;
170
+ } | null): PaymentReminderEffect;
143
171
  /**
144
172
  * Sends one reminder, at most once per round: re-checks that the invoice is
145
173
  * still overdue, claims the round, renders and sends with the invoice PDF,
package/dist/reminders.js CHANGED
@@ -122,6 +122,24 @@ export async function selectDueReminders(client, ledger, options = {}) {
122
122
  held: checked.flatMap(({ invoice, hold }) => (hold ? [{ invoice, hold }] : [])),
123
123
  };
124
124
  }
125
+ /**
126
+ * The effect of a reminder sent to `target`. Pass the send's outcome; pass
127
+ * null to reconstruct it from the target alone, which leaves amount and hash
128
+ * unknown (null).
129
+ */
130
+ export function paymentReminderEffect(target, sent) {
131
+ return {
132
+ kind: "payment_reminder",
133
+ to: target.to,
134
+ cc: [...(target.cc ?? [])],
135
+ test: target.test,
136
+ invoice: target.voucherNumber,
137
+ invoiceId: target.invoiceId,
138
+ amount: sent?.openAmount ?? null,
139
+ currency: target.currency,
140
+ contentSha256: sent?.contentSha256 ?? null,
141
+ };
142
+ }
125
143
  /**
126
144
  * Sends one reminder, at most once per round: re-checks that the invoice is
127
145
  * still overdue, claims the round, renders and sends with the invoice PDF,
@@ -4,9 +4,12 @@
4
4
  */
5
5
  import { type LexwareClient } from "./client.ts";
6
6
  export type SalesDocumentKind = "quotation" | "order-confirmation" | "invoice";
7
- /** One line of a sales document. */
8
- export interface LineItem {
7
+ /** A line with a price — counts towards the total unless it is optional. */
8
+ export interface PricedLineItem {
9
+ type?: "custom";
9
10
  name: string;
11
+ /** Free text under the name; Markdown (bold, italic, lists). Max. 2000 characters. */
12
+ description?: string;
10
13
  quantity: number;
11
14
  /** e.g. "Stück", "Stunde". */
12
15
  unit: string;
@@ -14,7 +17,28 @@ export interface LineItem {
14
17
  netPrice: number;
15
18
  /** 0, 7, or 19. */
16
19
  taxRate: number;
20
+ /** Quotations only: printed as "Optionale Position", not part of the total. */
21
+ optional?: boolean;
22
+ }
23
+ /** A line without a price: a heading or a paragraph between positions. */
24
+ export interface TextLineItem {
25
+ type: "text";
26
+ name: string;
27
+ description?: string;
17
28
  }
29
+ /** One line of a sales document. */
30
+ export type LineItem = PricedLineItem | TextLineItem;
31
+ export declare const isPricedLine: (item: LineItem) => item is PricedLineItem;
32
+ /** Whether a line adds to the document's total: priced and not optional. */
33
+ export declare const countsToTotal: (item: LineItem) => item is PricedLineItem;
34
+ /** Lexware's limits for the texts of a sales document (FAQ "Texts in Sales Vouchers"). */
35
+ export declare const SALES_TEXT_LIMITS: {
36
+ readonly introduction: 2000;
37
+ readonly remark: 2000;
38
+ readonly title: 25;
39
+ readonly lineItemName: 255;
40
+ readonly lineItemDescription: 2000;
41
+ };
18
42
  /** The type as the voucher list spells it. */
19
43
  export declare const VOUCHERLIST_TYPE: Record<SalesDocumentKind, string>;
20
44
  /**
@@ -29,8 +53,10 @@ export interface CreatedSalesDocument {
29
53
  }
30
54
  /**
31
55
  * 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.
56
+ * then no longer be deleted like a draft. `introduction` and `remark` replace
57
+ * the default texts of the account's settings — left out, the defaults stay.
58
+ * Only quotations know optional lines; on other kinds this throws instead of
59
+ * the API dropping the flag and counting the line.
34
60
  *
35
61
  * `precedingSalesVoucherId` links the document to its predecessor (a pursued
36
62
  * quotation becomes accepted) — only as a query parameter; in the body the API
@@ -49,6 +75,10 @@ export declare function createSalesDocument(client: LexwareClient, options: {
49
75
  /** Total discount in EUR on the net amount. */
50
76
  totalDiscountAbsolute?: number;
51
77
  precedingSalesVoucherId?: string;
78
+ /** Opening text; Markdown. Max. 2000 characters. */
79
+ introduction?: string;
80
+ /** Closing text; Markdown. Max. 2000 characters. */
81
+ remark?: string;
52
82
  }, signal?: AbortSignal): Promise<CreatedSalesDocument>;
53
83
  /** Whether a 406 rejects the pursue, not the payload — then a retry without the link makes sense. */
54
84
  export declare function isPursueRejection(error: unknown): boolean;
@@ -59,16 +89,24 @@ export interface SalesDocumentDetail {
59
89
  voucherStatus?: string;
60
90
  contactId?: string;
61
91
  contactName?: string;
62
- /** Net total over the priced lines — NaN for a document without any. */
92
+ /** Net total over the lines that count (priced, not optional) — NaN for a document without any. */
63
93
  lineItemsNet: number;
64
- lineItems: LineItem[];
94
+ /** The lines that count towards the total. */
95
+ lineItems: PricedLineItem[];
96
+ /** Optional lines (quotations) — not in the total. */
97
+ optionalItems: PricedLineItem[];
98
+ /**
99
+ * Optional or alternative lines — an order confirmation cannot be pursued
100
+ * from such a quotation (the API answers 406).
101
+ */
102
+ hasOptionalOrAlternative: boolean;
65
103
  /** The final net total — after a total discount. */
66
104
  totalNet?: number;
67
105
  /** A total discount is invisible in the lines. */
68
106
  discountAbsolute?: number;
69
107
  discountPercentage?: number;
70
108
  }
71
- /** A document reduced to the fields a comparison needs. Text lines (no price) are left out. */
109
+ /** A document reduced to the fields a comparison needs. Text lines (no price) are left out, optional ones kept apart. */
72
110
  export declare function getSalesDocument(client: LexwareClient, kind: SalesDocumentKind, id: string, signal?: AbortSignal): Promise<SalesDocumentDetail>;
73
111
  /** Whether an invoice still exists. Only a 404 counts as gone — any other error answers true. */
74
112
  export declare function invoiceExists(client: LexwareClient, id: string, signal?: AbortSignal): Promise<boolean>;
@@ -4,6 +4,11 @@
4
4
  */
5
5
  import { LexwareApiError, query } from "./client.js";
6
6
  import { DUPLICATE_TOTAL_TOLERANCE, round2 } from "./money.js";
7
+ export const isPricedLine = (item) => item.type !== "text";
8
+ /** Whether a line adds to the document's total: priced and not optional. */
9
+ export const countsToTotal = (item) => isPricedLine(item) && !item.optional;
10
+ /** Lexware's limits for the texts of a sales document (FAQ "Texts in Sales Vouchers"). */
11
+ export const SALES_TEXT_LIMITS = { introduction: 2000, remark: 2000, title: 25, lineItemName: 255, lineItemDescription: 2000 };
7
12
  const ENDPOINTS = {
8
13
  quotation: "/quotations",
9
14
  "order-confirmation": "/order-confirmations",
@@ -26,8 +31,10 @@ export const SALES_DOCUMENT_STATUSES = {
26
31
  };
27
32
  /**
28
33
  * 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.
34
+ * then no longer be deleted like a draft. `introduction` and `remark` replace
35
+ * the default texts of the account's settings — left out, the defaults stay.
36
+ * Only quotations know optional lines; on other kinds this throws instead of
37
+ * the API dropping the flag and counting the line.
31
38
  *
32
39
  * `precedingSalesVoucherId` links the document to its predecessor (a pursued
33
40
  * quotation becomes accepted) — only as a query parameter; in the body the API
@@ -35,23 +42,22 @@ export const SALES_DOCUMENT_STATUSES = {
35
42
  * the new document. An invalid pursue answers 406, see isPursueRejection.
36
43
  */
37
44
  export async function createSalesDocument(client, options, signal) {
45
+ if (options.kind !== "quotation" && options.items.some((item) => isPricedLine(item) && item.optional)) {
46
+ throw new Error(`Optional line items exist only on quotations, not on ${options.kind}`);
47
+ }
38
48
  const voucherDate = new Date().toISOString().replace("Z", "+00:00");
39
49
  const discount = options.totalDiscountAbsolute ?? 0;
40
50
  const payload = {
41
51
  voucherDate,
42
52
  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
- })),
53
+ lineItems: options.items.map(toApiLine),
50
54
  totalPrice: { currency: "EUR", ...(discount > 0 ? { totalDiscountAbsolute: round2(discount) } : {}) },
51
55
  taxConditions: { taxType: "net" },
52
56
  shippingConditions: { shippingType: "delivery", shippingDate: voucherDate },
53
57
  ...(options.title ? { title: options.title } : {}),
54
58
  ...(options.expirationDate ? { expirationDate: toIsoDateTime(options.expirationDate) } : {}),
59
+ ...(options.introduction ? { introduction: options.introduction } : {}),
60
+ ...(options.remark ? { remark: options.remark } : {}),
55
61
  };
56
62
  const params = query({ finalize: options.finalize ? true : undefined, precedingSalesVoucherId: options.precedingSalesVoucherId });
57
63
  const result = await client.request(`${ENDPOINTS[options.kind]}${params}`, {
@@ -61,6 +67,20 @@ export async function createSalesDocument(client, options, signal) {
61
67
  });
62
68
  return { id: result.id, resourceUri: result.resourceUri, url: client.voucherUrl(result.id) };
63
69
  }
70
+ function toApiLine(item) {
71
+ const description = item.description ? { description: item.description } : {};
72
+ if (!isPricedLine(item))
73
+ return { type: "text", name: item.name, ...description };
74
+ return {
75
+ type: "custom",
76
+ name: item.name,
77
+ ...description,
78
+ quantity: item.quantity,
79
+ unitName: item.unit,
80
+ unitPrice: { currency: "EUR", netAmount: item.netPrice, taxRatePercentage: item.taxRate },
81
+ ...(item.optional ? { optional: true } : {}),
82
+ };
83
+ }
64
84
  /** Whether a 406 rejects the pursue, not the payload — then a retry without the link makes sense. */
65
85
  export function isPursueRejection(error) {
66
86
  return error instanceof LexwareApiError && error.status === 406 && /preceding|pursue/i.test(error.responseBody);
@@ -72,18 +92,22 @@ function toIsoDateTime(date) {
72
92
  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
93
  return `${date}T00:00:00.000${offset}`;
74
94
  }
75
- /** A document reduced to the fields a comparison needs. Text lines (no price) are left out. */
95
+ /** A document reduced to the fields a comparison needs. Text lines (no price) are left out, optional ones kept apart. */
76
96
  export async function getSalesDocument(client, kind, id, signal) {
77
97
  const raw = await client.request(`${ENDPOINTS[kind]}/${encodeURIComponent(id)}`, { signal });
78
- const items = (raw.lineItems ?? [])
79
- .filter((item) => item.unitPrice !== undefined)
98
+ const lines = raw.lineItems ?? [];
99
+ const priced = lines
100
+ .filter((item) => item.type !== "text" && item.unitPrice !== undefined)
80
101
  .map((item) => ({
81
102
  name: item.name ?? "",
103
+ ...(item.description ? { description: item.description } : {}),
82
104
  quantity: item.quantity ?? 0,
83
105
  unit: item.unitName ?? "Stück",
84
106
  netPrice: item.unitPrice?.netAmount ?? 0,
85
107
  taxRate: item.unitPrice?.taxRatePercentage ?? 19,
108
+ ...(item.optional ? { optional: true } : {}),
86
109
  }));
110
+ const items = priced.filter((item) => !item.optional);
87
111
  return {
88
112
  id: raw.id,
89
113
  url: client.voucherUrl(raw.id),
@@ -93,6 +117,8 @@ export async function getSalesDocument(client, kind, id, signal) {
93
117
  contactName: raw.address?.name,
94
118
  lineItemsNet: items.length === 0 ? Number.NaN : round2(items.reduce((sum, item) => sum + item.quantity * item.netPrice, 0)),
95
119
  lineItems: items,
120
+ optionalItems: priced.filter((item) => item.optional),
121
+ hasOptionalOrAlternative: lines.some((item) => item.optional === true || (item.subItems?.length ?? 0) > 0),
96
122
  totalNet: raw.totalPrice?.totalNetAmount,
97
123
  discountAbsolute: raw.totalPrice?.totalDiscountAbsolute,
98
124
  discountPercentage: raw.totalPrice?.totalDiscountPercentage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevludwig/lexware-office",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Typed client and domain logic for the Lexware Office Public API: contacts, receivables, payment reminder rules.",
5
5
  "type": "module",
6
6
  "license": "MIT",