@kevludwig/lexware-office 0.2.0 → 0.4.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
@@ -27,7 +27,8 @@ One client is one rate-limit queue: requests are spaced (the API allows two per
27
27
  | `appUrl` | `https://app.lexware.de` | Base of the links `voucherUrl()` builds |
28
28
  | `minSpacingMs` | `550` | Spacing between two requests |
29
29
  | `timeoutMs` | `20000` | Per request, counted from when it fires |
30
- | `rateLimitBackoffMs` | `1000` | Wait before the retry after a 429 |
30
+ | `rateLimitBackoffMs` | `1000` | Wait before a retry after a 429 without `Retry-After`, growing with each retry |
31
+ | `rateLimitRetries` | `3` | How often a request answered with 429 is sent again; `Retry-After` is honoured (at most 10 s) |
31
32
  | `fetch` | global `fetch` | Your own implementation, e.g. in tests |
32
33
 
33
34
  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.
@@ -36,7 +37,7 @@ A failed request throws `LexwareApiError` with `status` and `responseBody`; `des
36
37
 
37
38
  **Contacts** — `getContact`, `findContactByNumber`, `findContactsByName`, `resolveContactByName` (exact name first, then the single candidate), `contactEmail`, `createCustomer`.
38
39
 
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
+ **Sales documents** — `createSalesDocument` for quotations, order confirmations, and invoices, with `finalize`, `voucherDate`, `shippingConditions` (service or delivery date or period), `paymentConditions` (`paymentTermLabel` phrases one as Lexware does), `taxConditions` (net, gross, and the vat-free types), a total discount in EUR or percent, `language`, `printLayoutId`, `deliveryTerms` (order confirmations), `xRechnung` (invoices), `precedingSalesVoucherId` for a document chain, `introduction` and `remark`, and line items that are priced (with an optional `description`, `discountPercentage`, `grossPrice`, an `article` reference; `optional` and `alternatives` on quotations) or `type: "text"` headings; `salesDocumentProblems` names what the API would drop or reject before anything is sent; `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. Account settings a document refers to: `listPrintLayouts` / `resolvePrintLayout`, `listPaymentConditions` / `defaultPaymentCondition`, `getProfile`, `listCountries` (with their tax classification), `getArticle`.
40
41
 
41
42
  **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
 
package/dist/client.d.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * One client per account: the API allows two requests per second for the
5
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.
6
+ * a minimum spacing. Retries on 429 — honouring Retry-After — cover other
7
+ * clients on the same account: serverless instances, a second app. Share one
8
+ * client per process; separate clients do not know about each other's
9
+ * requests, the retries only soften that.
9
10
  */
10
11
  export declare const DEFAULT_BASE_URL = "https://api.lexware.io/v1";
11
12
  /** Where a voucher opens in the Lexware Office web app. */
@@ -27,8 +28,10 @@ export interface LexwareClientOptions {
27
28
  * a file that arrives anyway. Default: 120 000.
28
29
  */
29
30
  uploadTimeoutMs?: number;
30
- /** Wait before retrying a request answered with 429, in ms. Default: 1 000. */
31
+ /** Wait before retrying a request answered with 429 without Retry-After, in ms; grows with each retry. Default: 1 000. */
31
32
  rateLimitBackoffMs?: number;
33
+ /** How often a request answered with 429 is sent again. Default: 3. */
34
+ rateLimitRetries?: number;
32
35
  /** A fetch implementation, e.g. for tests. Default: the global fetch. */
33
36
  fetch?: typeof fetch;
34
37
  }
package/dist/client.js CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * One client per account: the API allows two requests per second for the
5
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.
6
+ * a minimum spacing. Retries on 429 — honouring Retry-After — cover other
7
+ * clients on the same account: serverless instances, a second app. Share one
8
+ * client per process; separate clients do not know about each other's
9
+ * requests, the retries only soften that.
9
10
  */
10
11
  export const DEFAULT_BASE_URL = "https://api.lexware.io/v1";
11
12
  /** Where a voucher opens in the Lexware Office web app. */
@@ -54,6 +55,15 @@ export function query(params) {
54
55
  return rendered ? `?${rendered}` : "";
55
56
  }
56
57
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
58
+ /** The server's Retry-After in ms (seconds or an HTTP date), capped at 10 s; null without one. */
59
+ function retryAfterMs(response) {
60
+ const header = response.headers.get("retry-after")?.trim();
61
+ if (!header)
62
+ return null;
63
+ const seconds = Number(header);
64
+ const ms = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(header) - Date.now();
65
+ return Number.isFinite(ms) ? Math.min(Math.max(ms, 0), 10_000) : null;
66
+ }
57
67
  export function createLexwareClient(options) {
58
68
  if (!options.apiKey?.trim())
59
69
  throw new Error("Lexware Office: apiKey fehlt.");
@@ -63,6 +73,7 @@ export function createLexwareClient(options) {
63
73
  const timeoutMs = options.timeoutMs ?? 20_000;
64
74
  const uploadTimeoutMs = options.uploadTimeoutMs ?? 120_000;
65
75
  const backoffMs = options.rateLimitBackoffMs ?? 1_000;
76
+ const retries = options.rateLimitRetries ?? 3;
66
77
  const fetchImpl = options.fetch ?? fetch;
67
78
  // The queue: each request waits for the previous one to start plus the
68
79
  // spacing. The time limit starts when the request fires, not when queued.
@@ -81,9 +92,9 @@ export function createLexwareClient(options) {
81
92
  return run;
82
93
  }
83
94
  /**
84
- * One request, queued, with a single retry on 429 — then the API
85
- * demonstrably did not run it. A transport error after a POST is never
86
- * retried: the voucher may well exist already.
95
+ * One request, queued, retried on 429 — then the API demonstrably did not
96
+ * run it. A transport error after a POST is never retried: the voucher may
97
+ * well exist already.
87
98
  */
88
99
  async function send(path, init, limitMs = timeoutMs) {
89
100
  const method = init.method ?? "GET";
@@ -96,8 +107,9 @@ export function createLexwareClient(options) {
96
107
  });
97
108
  });
98
109
  let response = await fire();
99
- if (response.status === 429) {
100
- await sleep(backoffMs);
110
+ for (let attempt = 1; response.status === 429 && attempt <= retries; attempt++) {
111
+ await response.body?.cancel().catch(() => undefined);
112
+ await sleep(retryAfterMs(response) ?? backoffMs * attempt);
101
113
  response = await fire();
102
114
  }
103
115
  if (!response.ok) {
@@ -14,6 +14,14 @@ export interface Contact {
14
14
  number?: number;
15
15
  isCustomer: boolean;
16
16
  isVendor: boolean;
17
+ /** A company rather than a person. */
18
+ isCompany?: boolean;
19
+ /** The company's VAT ID (USt-IdNr.) — needed for the § 13b and intra-community tax types. */
20
+ vatRegistrationId?: string;
21
+ /** „Steuerfreie Rechnung erlauben" — needed for the third-country tax types. */
22
+ allowTaxFreeInvoices?: boolean;
23
+ /** ISO code of the first billing address. */
24
+ countryCode?: string;
17
25
  }
18
26
  /** A contact by its id. */
19
27
  export declare function getContact(client: LexwareClient, id: string, signal?: AbortSignal): Promise<Contact>;
package/dist/contacts.js CHANGED
@@ -16,6 +16,10 @@ function toContact(raw) {
16
16
  number: raw.roles?.customer?.number ?? raw.roles?.vendor?.number,
17
17
  isCustomer: raw.roles?.customer !== undefined,
18
18
  isVendor: raw.roles?.vendor !== undefined,
19
+ isCompany: raw.company !== undefined,
20
+ ...(raw.company?.vatRegistrationId?.trim() ? { vatRegistrationId: raw.company.vatRegistrationId.trim() } : {}),
21
+ ...(raw.company?.allowTaxFreeInvoices !== undefined ? { allowTaxFreeInvoices: raw.company.allowTaxFreeInvoices } : {}),
22
+ ...(raw.addresses?.billing?.[0]?.countryCode ? { countryCode: raw.addresses.billing[0].countryCode } : {}),
19
23
  };
20
24
  }
21
25
  /** A contact by its id. */
package/dist/index.d.ts CHANGED
@@ -5,7 +5,8 @@ export { downloadInvoicePdf, findOverdueInvoice, findOverdueInvoices, type Overd
5
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
- export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, netTotal, round2, taxFromGross, taxFromNet, type PricedLine, } from "./money.ts";
8
+ export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, lineNet, 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, 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";
11
+ export { SALES_DOCUMENT_STATUSES, SALES_TAX_TYPES, VALID_TAX_RATES, VOUCHERLIST_TYPE, countsToTotal, createSalesDocument, isVatFreeTaxType, paymentTermLabel, salesDocumentProblems, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPricedLine, isPursueRejection, SALES_TEXT_LIMITS, type AlternativeLineItem, type CreatedSalesDocument, type DuplicateSearchResult, type LineItem, type NewSalesDocument, type PaymentConditions, type PricedLineItem, type SalesLanguage, type SalesTaxType, type ShippingConditions, type TaxConditions, type SalesDocumentDetail, type SalesDocumentKind, type SalesListEntry, type TextLineItem, } from "./sales-documents.ts";
12
+ export { defaultPaymentCondition, getArticle, getProfile, listCountries, listPaymentConditions, listPrintLayouts, resolvePrintLayout, type Article, type Country, type CountryTaxClassification, type PaymentConditionTemplate, type PrintLayout, type Profile, } from "./settings.ts";
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ export { downloadInvoicePdf, findOverdueInvoice, findOverdueInvoices, } from "./
5
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
- export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, netTotal, round2, taxFromGross, taxFromNet, } from "./money.js";
8
+ export { DUPLICATE_TOTAL_TOLERANCE, ITEMS_TOTAL_TOLERANCE, lineNet, 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, countsToTotal, createSalesDocument, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPricedLine, isPursueRejection, SALES_TEXT_LIMITS, } from "./sales-documents.js";
11
+ export { SALES_DOCUMENT_STATUSES, SALES_TAX_TYPES, VALID_TAX_RATES, VOUCHERLIST_TYPE, countsToTotal, createSalesDocument, isVatFreeTaxType, paymentTermLabel, salesDocumentProblems, findDuplicateSalesDocument, findRecentSalesDocuments, getSalesDocument, invoiceExists, isPricedLine, isPursueRejection, SALES_TEXT_LIMITS, } from "./sales-documents.js";
12
+ export { defaultPaymentCondition, getArticle, getProfile, listCountries, listPaymentConditions, listPrintLayouts, resolvePrintLayout, } from "./settings.js";
package/dist/money.d.ts CHANGED
@@ -18,8 +18,12 @@ export declare function round2(value: number): number;
18
18
  export interface PricedLine {
19
19
  quantity: number;
20
20
  netPrice: number;
21
+ /** Discount on the line in percent. */
22
+ discountPercentage?: number;
21
23
  }
22
- /** Net total of line items, rounded to the cent. */
24
+ /** Net amount of one line after its discount, unrounded. */
25
+ export declare const lineNet: (line: PricedLine) => number;
26
+ /** Net total of line items after their discounts, rounded to the cent. */
23
27
  export declare function netTotal(lines: readonly PricedLine[]): number;
24
28
  /** Tax contained in a gross amount. */
25
29
  export declare function taxFromGross(gross: number, taxRatePercent: number): number;
package/dist/money.js CHANGED
@@ -16,9 +16,11 @@ export const DUPLICATE_TOTAL_TOLERANCE = 0.02;
16
16
  export function round2(value) {
17
17
  return Math.round((value + Number.EPSILON) * 100) / 100;
18
18
  }
19
- /** Net total of line items, rounded to the cent. */
19
+ /** Net amount of one line after its discount, unrounded. */
20
+ export const lineNet = (line) => line.quantity * line.netPrice * (1 - (line.discountPercentage ?? 0) / 100);
21
+ /** Net total of line items after their discounts, rounded to the cent. */
20
22
  export function netTotal(lines) {
21
- return round2(lines.reduce((sum, line) => sum + line.quantity * line.netPrice, 0));
23
+ return round2(lines.reduce((sum, line) => sum + lineNet(line), 0));
22
24
  }
23
25
  /** Tax contained in a gross amount. */
24
26
  export function taxFromGross(gross, taxRatePercent) {
@@ -42,7 +42,7 @@ export declare class MailNotSentError extends Error {
42
42
  });
43
43
  }
44
44
  export interface ReminderSender {
45
- /** Signature name, e.g. "Kevin Ludwig". */
45
+ /** Signature name, e.g. "Max Muster". */
46
46
  name: string;
47
47
  /** Lines under the name, e.g. the company and town. */
48
48
  signature?: readonly string[];
@@ -13,13 +13,32 @@ export interface PricedLineItem {
13
13
  quantity: number;
14
14
  /** e.g. "Stück", "Stunde". */
15
15
  unit: string;
16
- /** Net unit price in EUR. */
16
+ /**
17
+ * Net unit price in EUR. On a document with tax type `gross` the totals
18
+ * still count in net — derive it from `grossPrice` there.
19
+ */
17
20
  netPrice: number;
18
- /** 0, 7, or 19. */
21
+ /** Gross unit price in EUR — sent instead of `netPrice` when the document's tax type is `gross`. */
22
+ grossPrice?: number;
23
+ /** See VALID_TAX_RATES; 0 on every vat-free tax type. */
19
24
  taxRate: number;
25
+ /** Discount on this line in percent, up to 2 decimals. */
26
+ discountPercentage?: number;
27
+ /**
28
+ * A product or service from the account's articles. The line still carries
29
+ * all its fields — they may deviate from the article, which stays as it is.
30
+ */
31
+ article?: {
32
+ id: string;
33
+ type: "service" | "material";
34
+ };
20
35
  /** Quotations only: printed as "Optionale Position", not part of the total. */
21
36
  optional?: boolean;
37
+ /** Quotations only: alternatives offered instead of this line — printed under it, not part of the total. */
38
+ alternatives?: readonly AlternativeLineItem[];
22
39
  }
40
+ /** An alternative to a priced line of a quotation: priced itself, never optional, without alternatives of its own. */
41
+ export type AlternativeLineItem = Omit<PricedLineItem, "optional" | "alternatives">;
23
42
  /** A line without a price: a heading or a paragraph between positions. */
24
43
  export interface TextLineItem {
25
44
  type: "text";
@@ -35,10 +54,65 @@ export declare const countsToTotal: (item: LineItem) => item is PricedLineItem;
35
54
  export declare const SALES_TEXT_LIMITS: {
36
55
  readonly introduction: 2000;
37
56
  readonly remark: 2000;
57
+ readonly deliveryTerms: 255;
38
58
  readonly title: 25;
39
59
  readonly lineItemName: 255;
40
60
  readonly lineItemDescription: 2000;
41
61
  };
62
+ /**
63
+ * The tax rates Lexware Office accepts on German sales documents (cookbook
64
+ * "Rechnungen"). EU destination rates for distance sales come on top, with a
65
+ * `taxSubType` only.
66
+ */
67
+ export declare const VALID_TAX_RATES: readonly [0, 5, 7, 16, 19];
68
+ /** How the document is taxed. Every type but `net` and `gross` needs a referenced contact and 0 % on every line. */
69
+ export type SalesTaxType = "net" | "gross" | "vatfree" | "intraCommunitySupply" | "constructionService13b" | "externalService13b" | "thirdPartyCountryService" | "thirdPartyCountryDelivery" | "photovoltaicEquipment";
70
+ export declare const SALES_TAX_TYPES: readonly SalesTaxType[];
71
+ /** Whether a tax type charges no VAT — then every line must carry 0 %. */
72
+ export declare const isVatFreeTaxType: (type: SalesTaxType) => boolean;
73
+ export interface TaxConditions {
74
+ taxType: SalesTaxType;
75
+ /** B2C in the EU with `net` or `gross` only. */
76
+ taxSubType?: "distanceSales" | "electronicServices";
77
+ /** The printed note on a vat-free type; the account's default when absent. */
78
+ taxTypeNote?: string;
79
+ }
80
+ export interface PaymentConditions {
81
+ /** The printed text, e.g. „Zahlbar innerhalb von 14 Tagen ohne Abzug". */
82
+ paymentTermLabel?: string;
83
+ /** Days until payment is due. */
84
+ paymentTermDuration?: number;
85
+ /** Cash discount for payment within `discountRange` days. */
86
+ paymentDiscountConditions?: {
87
+ discountPercentage: number;
88
+ discountRange: number;
89
+ };
90
+ }
91
+ /**
92
+ * When the goods or the service are supplied — the date § 14 (4) No. 6 UStG
93
+ * asks of an invoice. `none` prints no date. The tax rates valid at
94
+ * `shippingDate` (a period: `shippingEndDate`) apply.
95
+ */
96
+ export type ShippingConditions = {
97
+ shippingType: "service" | "delivery";
98
+ shippingDate: string;
99
+ } | {
100
+ shippingType: "serviceperiod" | "deliveryperiod";
101
+ shippingDate: string;
102
+ shippingEndDate: string;
103
+ } | {
104
+ shippingType: "none";
105
+ };
106
+ export type SalesLanguage = "de" | "en";
107
+ /**
108
+ * The text Lexware Office prints for a payment term, in its own wording:
109
+ * „Zahlbar sofort, rein netto", „Zahlbar innerhalb von 14 Tagen ohne Abzug",
110
+ * „10 Tage 2 % Skonto, 30 Tage netto".
111
+ */
112
+ export declare function paymentTermLabel(days: number, discount?: {
113
+ percentage: number;
114
+ days: number;
115
+ }): string;
42
116
  /** The type as the voucher list spells it. */
43
117
  export declare const VOUCHERLIST_TYPE: Record<SalesDocumentKind, string>;
44
118
  /**
@@ -51,35 +125,63 @@ export interface CreatedSalesDocument {
51
125
  resourceUri?: string;
52
126
  url: string;
53
127
  }
54
- /**
55
- * Creates a sales document. `finalize` gives it a number right away — it can
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.
60
- *
61
- * `precedingSalesVoucherId` links the document to its predecessor (a pursued
62
- * quotation becomes accepted) — only as a query parameter; in the body the API
63
- * drops it silently. The content is not copied: this payload alone decides
64
- * the new document. An invalid pursue answers 406, see isPursueRejection.
65
- */
66
- export declare function createSalesDocument(client: LexwareClient, options: {
128
+ /** What a sales document is created with. Everything optional falls back to Lexware Office's defaults. */
129
+ export interface NewSalesDocument {
67
130
  kind: SalesDocumentKind;
68
131
  contactId: string;
69
132
  items: readonly LineItem[];
133
+ /** yyyy-MM-dd or a timestamp. Default: now. */
134
+ voucherDate?: string;
70
135
  /** Replaces the printed heading; Lexware's default when absent. Max. 25 characters. */
71
136
  title?: string;
72
137
  /** Quotations: valid until, yyyy-MM-dd or a timestamp. */
73
138
  expirationDate?: string;
74
139
  finalize?: boolean;
75
- /** Total discount in EUR on the net amount. */
140
+ /** Total discount in EUR. Not together with `totalDiscountPercentage`. */
76
141
  totalDiscountAbsolute?: number;
142
+ /** Total discount in percent of the net (or, on `gross`, the gross) total. Without either, a contact's default discount applies. */
143
+ totalDiscountPercentage?: number;
144
+ /** Default: `{ taxType: "net" }`. */
145
+ taxConditions?: TaxConditions;
146
+ /** Without them, Lexware takes the contact's payment term, else the account's default. */
147
+ paymentConditions?: PaymentConditions;
148
+ /**
149
+ * Order confirmations and invoices only — quotations have none. Default:
150
+ * delivery on the voucher date, which is what Lexware prints then.
151
+ */
152
+ shippingConditions?: ShippingConditions;
153
+ /** Print language and language of the default texts. Default: de. */
154
+ language?: SalesLanguage;
155
+ /** A print layout from listPrintLayouts; the account's default when absent. */
156
+ printLayoutId?: string;
157
+ /** Order confirmations only. Max. 255 characters; the contact's or account's default when absent. */
158
+ deliveryTerms?: string;
159
+ /** Invoices to an XRechnung contact: the Leitweg-ID, overriding the contact's. An empty string makes a standard invoice. */
160
+ xRechnung?: {
161
+ buyerReference: string;
162
+ };
77
163
  precedingSalesVoucherId?: string;
78
164
  /** Opening text; Markdown. Max. 2000 characters. */
79
165
  introduction?: string;
80
166
  /** Closing text; Markdown. Max. 2000 characters. */
81
167
  remark?: string;
82
- }, signal?: AbortSignal): Promise<CreatedSalesDocument>;
168
+ }
169
+ /**
170
+ * Creates a sales document. `finalize` gives it a number right away — it can
171
+ * then no longer be deleted like a draft. Texts, payment conditions, delivery
172
+ * terms, print layout and total discount left out take the account's (or the
173
+ * contact's) defaults. Combinations the API documents for another kind only —
174
+ * optional lines outside a quotation, shipping conditions on one, … — throw
175
+ * instead of reaching the API, which would drop or reject them.
176
+ *
177
+ * `precedingSalesVoucherId` links the document to its predecessor (a pursued
178
+ * quotation becomes accepted) — only as a query parameter; in the body the API
179
+ * drops it silently. The content is not copied: this payload alone decides
180
+ * the new document. An invalid pursue answers 406, see isPursueRejection.
181
+ */
182
+ export declare function createSalesDocument(client: LexwareClient, options: NewSalesDocument, signal?: AbortSignal): Promise<CreatedSalesDocument>;
183
+ /** The checks createSalesDocument runs before sending — exported so a caller can run them before an approval. */
184
+ export declare function salesDocumentProblems(options: NewSalesDocument): string[];
83
185
  /** Whether a 406 rejects the pursue, not the payload — then a retry without the link makes sense. */
84
186
  export declare function isPursueRejection(error: unknown): boolean;
85
187
  export interface SalesDocumentDetail {
@@ -3,12 +3,42 @@
3
3
  * one payload shape; only the endpoint and a few optional fields differ.
4
4
  */
5
5
  import { LexwareApiError, query } from "./client.js";
6
- import { DUPLICATE_TOTAL_TOLERANCE, round2 } from "./money.js";
6
+ import { DUPLICATE_TOTAL_TOLERANCE, netTotal, round2 } from "./money.js";
7
7
  export const isPricedLine = (item) => item.type !== "text";
8
8
  /** Whether a line adds to the document's total: priced and not optional. */
9
9
  export const countsToTotal = (item) => isPricedLine(item) && !item.optional;
10
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 };
11
+ export const SALES_TEXT_LIMITS = { introduction: 2000, remark: 2000, deliveryTerms: 255, title: 25, lineItemName: 255, lineItemDescription: 2000 };
12
+ /**
13
+ * The tax rates Lexware Office accepts on German sales documents (cookbook
14
+ * "Rechnungen"). EU destination rates for distance sales come on top, with a
15
+ * `taxSubType` only.
16
+ */
17
+ export const VALID_TAX_RATES = [0, 5, 7, 16, 19];
18
+ export const SALES_TAX_TYPES = [
19
+ "net",
20
+ "gross",
21
+ "vatfree",
22
+ "intraCommunitySupply",
23
+ "constructionService13b",
24
+ "externalService13b",
25
+ "thirdPartyCountryService",
26
+ "thirdPartyCountryDelivery",
27
+ "photovoltaicEquipment",
28
+ ];
29
+ /** Whether a tax type charges no VAT — then every line must carry 0 %. */
30
+ export const isVatFreeTaxType = (type) => type !== "net" && type !== "gross";
31
+ /**
32
+ * The text Lexware Office prints for a payment term, in its own wording:
33
+ * „Zahlbar sofort, rein netto", „Zahlbar innerhalb von 14 Tagen ohne Abzug",
34
+ * „10 Tage 2 % Skonto, 30 Tage netto".
35
+ */
36
+ export function paymentTermLabel(days, discount) {
37
+ if (discount) {
38
+ return `${discount.days} Tage ${discount.percentage.toLocaleString("de-DE", { maximumFractionDigits: 2 })} % Skonto, ${days} Tage netto`;
39
+ }
40
+ return days === 0 ? "Zahlbar sofort, rein netto" : `Zahlbar innerhalb von ${days} ${days === 1 ? "Tag" : "Tagen"} ohne Abzug`;
41
+ }
12
42
  const ENDPOINTS = {
13
43
  quotation: "/quotations",
14
44
  "order-confirmation": "/order-confirmations",
@@ -31,10 +61,11 @@ export const SALES_DOCUMENT_STATUSES = {
31
61
  };
32
62
  /**
33
63
  * Creates a sales document. `finalize` gives it a number right away — it can
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.
64
+ * then no longer be deleted like a draft. Texts, payment conditions, delivery
65
+ * terms, print layout and total discount left out take the account's (or the
66
+ * contact's) defaults. Combinations the API documents for another kind only —
67
+ * optional lines outside a quotation, shipping conditions on one, … — throw
68
+ * instead of reaching the API, which would drop or reject them.
38
69
  *
39
70
  * `precedingSalesVoucherId` links the document to its predecessor (a pursued
40
71
  * quotation becomes accepted) — only as a query parameter; in the body the API
@@ -42,18 +73,27 @@ export const SALES_DOCUMENT_STATUSES = {
42
73
  * the new document. An invalid pursue answers 406, see isPursueRejection.
43
74
  */
44
75
  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
- }
48
- const voucherDate = new Date().toISOString().replace("Z", "+00:00");
49
- const discount = options.totalDiscountAbsolute ?? 0;
76
+ assertValidSalesDocument(options);
77
+ const voucherDate = options.voucherDate ? toIsoDateTime(options.voucherDate) : new Date().toISOString().replace("Z", "+00:00");
78
+ const taxConditions = options.taxConditions ?? { taxType: "net" };
79
+ const gross = taxConditions.taxType === "gross";
80
+ const totalPrice = {
81
+ currency: "EUR",
82
+ ...((options.totalDiscountAbsolute ?? 0) > 0 ? { totalDiscountAbsolute: round2(options.totalDiscountAbsolute) } : {}),
83
+ ...((options.totalDiscountPercentage ?? 0) > 0 ? { totalDiscountPercentage: round2(options.totalDiscountPercentage) } : {}),
84
+ };
50
85
  const payload = {
51
86
  voucherDate,
52
87
  address: { contactId: options.contactId },
53
- lineItems: options.items.map(toApiLine),
54
- totalPrice: { currency: "EUR", ...(discount > 0 ? { totalDiscountAbsolute: round2(discount) } : {}) },
55
- taxConditions: { taxType: "net" },
56
- shippingConditions: { shippingType: "delivery", shippingDate: voucherDate },
88
+ lineItems: options.items.map((item) => toApiLine(item, gross)),
89
+ totalPrice,
90
+ taxConditions,
91
+ ...(options.kind === "quotation" ? {} : { shippingConditions: toApiShipping(options.shippingConditions, voucherDate) }),
92
+ ...(options.paymentConditions ? { paymentConditions: options.paymentConditions } : {}),
93
+ ...(options.language ? { language: options.language } : {}),
94
+ ...(options.printLayoutId ? { printLayoutId: options.printLayoutId } : {}),
95
+ ...(options.deliveryTerms ? { deliveryTerms: options.deliveryTerms } : {}),
96
+ ...(options.xRechnung ? { xRechnung: options.xRechnung } : {}),
57
97
  ...(options.title ? { title: options.title } : {}),
58
98
  ...(options.expirationDate ? { expirationDate: toIsoDateTime(options.expirationDate) } : {}),
59
99
  ...(options.introduction ? { introduction: options.introduction } : {}),
@@ -67,18 +107,95 @@ export async function createSalesDocument(client, options, signal) {
67
107
  });
68
108
  return { id: result.id, resourceUri: result.resourceUri, url: client.voucherUrl(result.id) };
69
109
  }
70
- function toApiLine(item) {
110
+ /** The checks createSalesDocument runs before sending — exported so a caller can run them before an approval. */
111
+ export function salesDocumentProblems(options) {
112
+ const problems = [];
113
+ const { kind } = options;
114
+ const priced = options.items.filter(isPricedLine);
115
+ const lines = priced.flatMap((item) => [item, ...(item.alternatives ?? [])]);
116
+ const taxType = options.taxConditions?.taxType ?? "net";
117
+ if (kind !== "quotation" && priced.some((item) => item.optional))
118
+ problems.push(`Optional line items exist only on quotations, not on ${kind}`);
119
+ if (kind !== "quotation" && priced.some((item) => (item.alternatives?.length ?? 0) > 0))
120
+ problems.push(`Alternative line items exist only on quotations, not on ${kind}`);
121
+ if (priced.some((item) => item.optional && (item.alternatives?.length ?? 0) > 0))
122
+ problems.push("An optional line item cannot have alternatives");
123
+ if (kind === "quotation" && options.shippingConditions)
124
+ problems.push("Quotations have no shipping conditions");
125
+ if (kind !== "order-confirmation" && options.deliveryTerms)
126
+ problems.push(`Delivery terms exist only on order confirmations, not on ${kind}`);
127
+ if (kind !== "invoice" && options.xRechnung)
128
+ problems.push(`XRechnung properties exist only on invoices, not on ${kind}`);
129
+ if (kind !== "quotation" && options.expirationDate)
130
+ problems.push(`An expiration date exists only on quotations, not on ${kind}`);
131
+ if ((options.deliveryTerms?.length ?? 0) > SALES_TEXT_LIMITS.deliveryTerms)
132
+ problems.push(`Delivery terms longer than ${SALES_TEXT_LIMITS.deliveryTerms} characters`);
133
+ if ((options.totalDiscountAbsolute ?? 0) > 0 && (options.totalDiscountPercentage ?? 0) > 0)
134
+ problems.push("Either an absolute or a percentage total discount, not both");
135
+ if ((options.totalDiscountPercentage ?? 0) > 100)
136
+ problems.push("A total discount above 100 %");
137
+ if (isVatFreeTaxType(taxType) && lines.some((item) => item.taxRate !== 0))
138
+ problems.push(`Tax type ${taxType} needs 0 % on every line`);
139
+ if (options.taxConditions?.taxSubType && isVatFreeTaxType(taxType))
140
+ problems.push("A tax subtype goes with net or gross only");
141
+ if (!options.taxConditions?.taxSubType && lines.some((item) => !VALID_TAX_RATES.includes(item.taxRate))) {
142
+ problems.push(`Tax rates other than ${VALID_TAX_RATES.join(", ")} % need a tax subtype (EU distance sales)`);
143
+ }
144
+ if (taxType === "gross" && lines.some((item) => item.grossPrice === undefined))
145
+ problems.push("Tax type gross needs a gross price on every priced line");
146
+ if (lines.some((item) => (item.discountPercentage ?? 0) < 0 || (item.discountPercentage ?? 0) > 100))
147
+ problems.push("A line discount outside 0–100 %");
148
+ const shipping = options.shippingConditions;
149
+ if (shipping && shipping.shippingType !== "none") {
150
+ if (!isDate(shipping.shippingDate))
151
+ problems.push(`Shipping date ${shipping.shippingDate} is no date`);
152
+ if ("shippingEndDate" in shipping) {
153
+ if (!isDate(shipping.shippingEndDate))
154
+ problems.push(`Shipping end date ${shipping.shippingEndDate} is no date`);
155
+ else if (dayOf(shipping.shippingEndDate) < dayOf(shipping.shippingDate))
156
+ problems.push("The shipping period ends before it starts");
157
+ }
158
+ }
159
+ const payment = options.paymentConditions;
160
+ if (payment?.paymentDiscountConditions && payment.paymentTermDuration !== undefined && payment.paymentDiscountConditions.discountRange > payment.paymentTermDuration) {
161
+ problems.push("The cash discount runs longer than the payment term");
162
+ }
163
+ return problems;
164
+ }
165
+ function assertValidSalesDocument(options) {
166
+ const problems = salesDocumentProblems(options);
167
+ if (problems.length > 0)
168
+ throw new Error(problems.join("; "));
169
+ }
170
+ const isDate = (value) => !Number.isNaN(new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T12:00:00Z` : value).getTime());
171
+ const dayOf = (value) => value.slice(0, 10);
172
+ function toApiShipping(shipping, voucherDate) {
173
+ if (!shipping)
174
+ return { shippingType: "delivery", shippingDate: voucherDate };
175
+ if (shipping.shippingType === "none")
176
+ return { shippingType: "none" };
177
+ return {
178
+ shippingType: shipping.shippingType,
179
+ shippingDate: toIsoDateTime(shipping.shippingDate),
180
+ ...("shippingEndDate" in shipping ? { shippingEndDate: toIsoDateTime(shipping.shippingEndDate) } : {}),
181
+ };
182
+ }
183
+ function toApiLine(item, gross) {
71
184
  const description = item.description ? { description: item.description } : {};
72
185
  if (!isPricedLine(item))
73
186
  return { type: "text", name: item.name, ...description };
74
187
  return {
75
- type: "custom",
188
+ ...(item.article ? { id: item.article.id, type: item.article.type } : { type: "custom" }),
76
189
  name: item.name,
77
190
  ...description,
78
191
  quantity: item.quantity,
79
192
  unitName: item.unit,
80
- unitPrice: { currency: "EUR", netAmount: item.netPrice, taxRatePercentage: item.taxRate },
193
+ unitPrice: gross
194
+ ? { currency: "EUR", grossAmount: item.grossPrice, taxRatePercentage: item.taxRate }
195
+ : { currency: "EUR", netAmount: item.netPrice, taxRatePercentage: item.taxRate },
196
+ ...((item.discountPercentage ?? 0) > 0 ? { discountPercentage: item.discountPercentage } : {}),
81
197
  ...(item.optional ? { optional: true } : {}),
198
+ ...(item.alternatives?.length ? { subItems: item.alternatives.map((alternative) => ({ ...toApiLine(alternative, gross), alternative: true })) } : {}),
82
199
  };
83
200
  }
84
201
  /** Whether a 406 rejects the pursue, not the payload — then a retry without the link makes sense. */
@@ -105,6 +222,7 @@ export async function getSalesDocument(client, kind, id, signal) {
105
222
  unit: item.unitName ?? "Stück",
106
223
  netPrice: item.unitPrice?.netAmount ?? 0,
107
224
  taxRate: item.unitPrice?.taxRatePercentage ?? 19,
225
+ ...(item.discountPercentage ? { discountPercentage: item.discountPercentage } : {}),
108
226
  ...(item.optional ? { optional: true } : {}),
109
227
  }));
110
228
  const items = priced.filter((item) => !item.optional);
@@ -115,7 +233,7 @@ export async function getSalesDocument(client, kind, id, signal) {
115
233
  voucherStatus: raw.voucherStatus,
116
234
  contactId: raw.address?.contactId,
117
235
  contactName: raw.address?.name,
118
- lineItemsNet: items.length === 0 ? Number.NaN : round2(items.reduce((sum, item) => sum + item.quantity * item.netPrice, 0)),
236
+ lineItemsNet: items.length === 0 ? Number.NaN : netTotal(items),
119
237
  lineItems: items,
120
238
  optionalItems: priced.filter((item) => item.optional),
121
239
  hasOptionalOrAlternative: lines.some((item) => item.optional === true || (item.subItems?.length ?? 0) > 0),
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Account settings a sales document refers to: print layouts, payment
3
+ * conditions, the profile's tax setup, countries, articles. Read-only.
4
+ */
5
+ import { type LexwareClient } from "./client.ts";
6
+ export interface PrintLayout {
7
+ id: string;
8
+ name: string;
9
+ default: boolean;
10
+ }
11
+ export declare function listPrintLayouts(client: LexwareClient, signal?: AbortSignal): Promise<PrintLayout[]>;
12
+ /** A print layout by id or by name (case-insensitive); null if none or several match. */
13
+ export declare function resolvePrintLayout(client: LexwareClient, nameOrId: string, signal?: AbortSignal): Promise<PrintLayout | null>;
14
+ export interface PaymentConditionTemplate {
15
+ id: string;
16
+ /** As configured, with placeholders such as {paymentRange}. */
17
+ paymentTermLabelTemplate: string;
18
+ paymentTermDuration?: number;
19
+ paymentDiscountConditions?: {
20
+ discountPercentage: number;
21
+ discountRange: number;
22
+ };
23
+ organizationDefault: boolean;
24
+ }
25
+ export declare function listPaymentConditions(client: LexwareClient, signal?: AbortSignal): Promise<PaymentConditionTemplate[]>;
26
+ /**
27
+ * The account's default payment condition — what a document without payment
28
+ * conditions gets unless its contact has its own (which the API does not
29
+ * expose). The label with its placeholders filled in.
30
+ */
31
+ export declare function defaultPaymentCondition(client: LexwareClient, signal?: AbortSignal): Promise<{
32
+ label: string;
33
+ duration?: number;
34
+ discount?: {
35
+ percentage: number;
36
+ days: number;
37
+ };
38
+ } | null>;
39
+ export interface Profile {
40
+ organizationId: string;
41
+ companyName?: string;
42
+ /** The account's tax type: net, gross, or vatfree. */
43
+ taxType?: "net" | "gross" | "vatfree";
44
+ /** Kleinunternehmer according to § 19 UStG. */
45
+ smallBusiness?: boolean;
46
+ distanceSalesPrinciple?: "ORIGIN" | "DESTINATION";
47
+ }
48
+ export declare function getProfile(client: LexwareClient, signal?: AbortSignal): Promise<Profile>;
49
+ export type CountryTaxClassification = "de" | "intraCommunity" | "thirdPartyCountry";
50
+ export interface Country {
51
+ countryCode: string;
52
+ countryNameDE?: string;
53
+ countryNameEN?: string;
54
+ taxClassification: CountryTaxClassification;
55
+ }
56
+ export declare function listCountries(client: LexwareClient, signal?: AbortSignal): Promise<Country[]>;
57
+ export interface Article {
58
+ id: string;
59
+ title: string;
60
+ /** As a line item spells it. */
61
+ lineType: "service" | "material";
62
+ articleNumber?: string;
63
+ unitName?: string;
64
+ netPrice?: number;
65
+ grossPrice?: number;
66
+ taxRate?: number;
67
+ }
68
+ export declare function getArticle(client: LexwareClient, id: string, signal?: AbortSignal): Promise<Article>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Account settings a sales document refers to: print layouts, payment
3
+ * conditions, the profile's tax setup, countries, articles. Read-only.
4
+ */
5
+ import {} from "./client.js";
6
+ export async function listPrintLayouts(client, signal) {
7
+ return (await client.request("/print-layouts", { signal })) ?? [];
8
+ }
9
+ /** A print layout by id or by name (case-insensitive); null if none or several match. */
10
+ export async function resolvePrintLayout(client, nameOrId, signal) {
11
+ const needle = nameOrId.trim().toLowerCase();
12
+ const layouts = await listPrintLayouts(client, signal);
13
+ const byId = layouts.find((layout) => layout.id.toLowerCase() === needle);
14
+ if (byId)
15
+ return byId;
16
+ const byName = layouts.filter((layout) => layout.name.trim().toLowerCase() === needle);
17
+ return byName.length === 1 ? byName[0] : null;
18
+ }
19
+ export async function listPaymentConditions(client, signal) {
20
+ return (await client.request("/payment-conditions", { signal })) ?? [];
21
+ }
22
+ /**
23
+ * The account's default payment condition — what a document without payment
24
+ * conditions gets unless its contact has its own (which the API does not
25
+ * expose). The label with its placeholders filled in.
26
+ */
27
+ export async function defaultPaymentCondition(client, signal) {
28
+ const found = (await listPaymentConditions(client, signal)).find((condition) => condition.organizationDefault);
29
+ if (!found)
30
+ return null;
31
+ const discount = found.paymentDiscountConditions;
32
+ const label = found.paymentTermLabelTemplate
33
+ .replace(/\{paymentRange\}/g, String(found.paymentTermDuration ?? ""))
34
+ .replace(/\{discountRange\}/g, String(discount?.discountRange ?? ""))
35
+ .replace(/\{discount\}/g, discount ? `${discount.discountPercentage.toLocaleString("de-DE", { maximumFractionDigits: 2 })} %` : "");
36
+ return {
37
+ label,
38
+ ...(found.paymentTermDuration !== undefined ? { duration: found.paymentTermDuration } : {}),
39
+ ...(discount ? { discount: { percentage: discount.discountPercentage, days: discount.discountRange } } : {}),
40
+ };
41
+ }
42
+ export async function getProfile(client, signal) {
43
+ return client.request("/profile", { signal });
44
+ }
45
+ export async function listCountries(client, signal) {
46
+ return (await client.request("/countries", { signal })) ?? [];
47
+ }
48
+ export async function getArticle(client, id, signal) {
49
+ const raw = await client.request(`/articles/${encodeURIComponent(id)}`, { signal });
50
+ return {
51
+ id: raw.id,
52
+ title: raw.title ?? "",
53
+ lineType: raw.type === "PRODUCT" ? "material" : "service",
54
+ ...(raw.articleNumber ? { articleNumber: raw.articleNumber } : {}),
55
+ ...(raw.unitName ? { unitName: raw.unitName } : {}),
56
+ ...(raw.price?.netPrice !== undefined ? { netPrice: raw.price.netPrice } : {}),
57
+ ...(raw.price?.grossPrice !== undefined ? { grossPrice: raw.price.grossPrice } : {}),
58
+ ...(raw.price?.taxRate !== undefined ? { taxRate: raw.price.taxRate } : {}),
59
+ };
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevludwig/lexware-office",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",