@kevludwig/lexware-office 0.1.4 → 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 +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/sales-documents.d.ts +45 -7
- package/dist/sales-documents.js +38 -12
- package/package.json +1 -1
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,
|
|
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
|
@@ -8,4 +8,4 @@ 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
|
@@ -8,4 +8,4 @@ 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";
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { type LexwareClient } from "./client.ts";
|
|
6
6
|
export type SalesDocumentKind = "quotation" | "order-confirmation" | "invoice";
|
|
7
|
-
/**
|
|
8
|
-
export interface
|
|
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.
|
|
33
|
-
*
|
|
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
|
|
92
|
+
/** Net total over the lines that count (priced, not optional) — NaN for a document without any. */
|
|
63
93
|
lineItemsNet: number;
|
|
64
|
-
|
|
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>;
|
package/dist/sales-documents.js
CHANGED
|
@@ -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.
|
|
30
|
-
*
|
|
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(
|
|
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
|
|
79
|
-
|
|
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