@ekwo-ai/factur-x 0.11.1 → 0.12.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+ - **Reading a received invoice.** `readCii()` reads a CII D16B
11
+ `CrossIndustryInvoice` of any profile — MINIMUM, BASIC WL, BASIC, EN 16931,
12
+ EXTENDED, ZUGFeRD 2.x spellings included — and says which profile it
13
+ declares. `readFacturX()` of `@ekwo-ai/factur-x/pdf` reads it out of the
14
+ PDF. The result is the `ReceivedInvoice` that `readUbl()` of
15
+ `@ekwo-ai/peppol-ubl` returns, from byte-for-byte copies of the same files:
16
+ figures as the decimal text the file wrote, the EN 16931 arithmetic reported
17
+ in `violations`, an `InvoiceFileError` with a code for what is not an
18
+ invoice.
19
+ - `extractFacturX()` also finds `xrechnung.xml`, the name ZUGFeRD gives the
20
+ XML of its XRECHNUNG profile.
21
+ - `buildXmpMetadata()` takes `author`, `subject`, `keywords` and
22
+ `creationDate`, written as `dc:creator`, `dc:description`, `pdf:Keywords`
23
+ and `xmp:CreateDate`. Without them it writes what it wrote before.
24
+
25
+ ### Fixed
26
+ - **The XMP of `embedFacturX()` now says what the information dictionary
27
+ says.** It wrote the tool as `dc:creator` beside an `Author` naming the
28
+ seller, the time of embedding as `xmp:CreateDate` beside the PDF's own
29
+ `CreationDate`, and nothing for `Subject` or `Keywords`; PDF/A wants each
30
+ entry of the dictionary and its XMP counterpart to agree. The packet is now
31
+ written from the dictionary of the PDF it is given, after the title, creator,
32
+ producer and dates of the options are set in it.
33
+
9
34
  ## [0.2.0] — 2026-09-14
10
35
 
11
36
  Released with Ekwo OS `v0.2.0`, the first tagged release of the repository this
package/README.md CHANGED
@@ -7,7 +7,9 @@ Factur-X / ZUGFeRD electronic invoices in TypeScript.
7
7
  `BR-IC`, `BR-G`, `BR-O`). Profiles MINIMUM, BASIC WL, BASIC (default), EN 16931, EXTENDED.
8
8
  - **PDF/A-3 embedding.** Attaches the XML as `factur-x.xml` with `AFRelationship /Alternative`
9
9
  and writes the XMP packet with the Factur-X extension schema.
10
- - **Extraction.** Reads `factur-x.xml` or `zugferd-invoice.xml` back from any PDF.
10
+ - **Extraction.** Reads `factur-x.xml`, `zugferd-invoice.xml` or `xrechnung.xml` back from any PDF.
11
+ - **Reading a received invoice.** `readCii` and `readFacturX` turn the CII of any profile
12
+ into the same plain result `@ekwo-ai/peppol-ubl` returns from UBL — see [Reading](#reading).
11
13
  - **Totals you can trust.** One VAT group per category and rate, VAT computed on the rounded
12
14
  basis, prepayments, credit notes referencing the original invoice.
13
15
  - Zero dependencies for the XML part; `pdf-lib` only for the PDF part (separate entry point).
@@ -92,17 +94,91 @@ back to `C62` (unit). See `UNIT_CODES` and `toUnitCode`.
92
94
  | `buildXmpMetadata`, `CONFORMANCE_LEVELS`, `GUIDELINES`, `DOCUMENT_TYPE_CODES` | Constants of the specification. |
93
95
  | `embedFacturX(pdf, xml, { profile?, title?, creator?, producer?, date? })` *(`/pdf`)* | Returns a new PDF with the XML attached and the PDF/A-3 XMP packet. |
94
96
  | `extractFacturX(pdf)` *(`/pdf`)* | `{ filename, xml }` or `null`. |
97
+ | `readCii(xml, { maxBytes?, maxDepth?, maxElements? })` | `{ invoice, violations, profile }`, see [Reading](#reading). |
98
+ | `readFacturX(pdf, options?)` *(`/pdf`)* | The same, read out of the PDF, with the `filename` of the attachment. |
99
+ | `InvoiceFileError`, `profileOf`, `checkReceived`, `isValidIban`, `CREDIT_NOTE_TYPE_CODES` | What reading refuses, and the helpers it uses. |
95
100
 
96
101
  ## Scope and limitations
97
102
 
98
103
  - Allowances and charges (BG-20, BG-21, BG-27, BG-28), gross prices with discounts and
99
104
  multiple deliveries are not modelled yet.
100
- - The PDF part embeds the data and declares PDF/A-3; it does not convert the visual PDF to
101
- PDF/A (fonts, colour profiles, output intent). Validate the result with the tool of your
105
+ - The PDF part embeds the data, declares PDF/A-3 and writes an XMP packet that agrees with
106
+ the document information dictionary; it does not convert the visual PDF to PDF/A (fonts,
107
+ colour profiles, output intent). A PDF rendered by
108
+ [`@ekwo-ai/invoice-pdf`](../invoice-pdf/README.md#pdfa-3) has them, and veraPDF finds no
109
+ failed rule of PDF/A-3b in it once embedded; validate any other PDF with the tool of your
102
110
  platform before going live.
103
111
  - No XSD or Schematron validation is performed. Test against a validator such as the FNFE
104
112
  Factur-X validator or the Mustang project.
105
113
 
114
+ ## Reading
115
+
116
+ The invoice a supplier sends you as Factur-X or ZUGFeRD, turned into a plain object a
117
+ purchase draft can be made from.
118
+
119
+ ```ts
120
+ import { readCii, InvoiceFileError } from '@ekwo-ai/factur-x';
121
+ import { readFacturX } from '@ekwo-ai/factur-x/pdf';
122
+
123
+ const { invoice, violations, profile, filename } = await readFacturX(pdfBytes);
124
+ profile; // 'minimum' | 'basic-wl' | 'basic' | 'en16931' | 'extended' | null
125
+ invoice.kind; // 'invoice' | 'credit_note', from the type code
126
+ invoice.totals.payable; // '648.40', a decimal as text, never a number
127
+
128
+ const same = readCii(xmlBytesOrText); // the XML alone, without pdf-lib
129
+ ```
130
+
131
+ - `readCii(input: string | Uint8Array, options?: ReadOptions): ReceivedCiiFile` reads a
132
+ CII D16B `CrossIndustryInvoice` — Factur-X 1.0, ZUGFeRD 2.x, an XRechnung in CII — and
133
+ returns `{ invoice, violations, profile }`.
134
+ - `readFacturX(pdf: Uint8Array | ArrayBuffer, options?: ReadOptions): Promise<ReceivedFacturX>`
135
+ *(`/pdf`)* finds the XML attached to the PDF and reads it with `readCii`; the result
136
+ adds the `filename` it was attached under.
137
+ - `profileOf(guideline)` is the profile a guideline identifier (BT-24) declares. An
138
+ EN 16931 CIUS (`urn:cen.eu:en16931:2017#compliant#…`, XRechnung for one) reads as
139
+ `en16931`; an identifier this package does not know is `null`, and
140
+ `invoice.customizationId` keeps what the file wrote.
141
+
142
+ **The shape is the UBL reader's.** `invoice` is a `ReceivedInvoice`, the type
143
+ `readUbl` of `@ekwo-ai/peppol-ubl` returns: each field names the EN 16931 business term it
144
+ holds, and a business term is in the same field whichever syntax carried it — the test
145
+ suite reads one invoice written in both syntaxes and gets the same object from both. The
146
+ two bricks do not depend on each other; they hold byte-for-byte copies of the files that
147
+ define the shape, the checks, the XML reader and the decimals, and a test keeps them
148
+ identical. `syntax` says which one was read.
149
+
150
+ **Nothing is recomputed, nothing is defaulted.** A figure is the text the file wrote,
151
+ checked to be a decimal; an absent element is `null` — a MINIMUM or BASIC WL file has no
152
+ line and no line total, and comes back so. The arithmetic of EN 16931 (BR-CO-10 to
153
+ BR-CO-17) is checked on exact decimals and reported in `violations`. CII-specific
154
+ findings are there too: `invalid_iban` (an `IBANID` whose check digits fail, still
155
+ returned as written), `invalid_attachment`, `unsupported_date_format` (a date in a
156
+ format other than 102, returned as `null`). Where CII writes something once for the
157
+ document that UBL writes per means of payment — the payment reference (BT-83) and the
158
+ direct debit mandate (BT-89) — it is returned on every means of payment.
159
+
160
+ **What is not an invoice throws an `InvoiceFileError`** with a `code`: the codes of the
161
+ UBL reader (`malformed_xml`, `unsupported_encoding`, `doctype_forbidden`,
162
+ `undefined_entity`, `too_large`, `too_deep`, `too_many_elements`, `not_an_invoice`,
163
+ `missing_element`, `invalid_value`), plus `not_a_pdf` and `no_embedded_invoice` from
164
+ `readFacturX`. A line is required except in the MINIMUM and BASIC WL profiles. No raw
165
+ error of the XML parser or of `pdf-lib` escapes. The XML is read by a strict reader of
166
+ the package's own: no DOCTYPE and so no entity expansion, UTF-8 only, size, depth and
167
+ element limits.
168
+
169
+ **Limits.**
170
+
171
+ - The PDF is opened with `pdf-lib`, already the dependency of the `/pdf` entry point:
172
+ the attachment is found in the `EmbeddedFiles` name tree of the catalogue, under one
173
+ of the three names above. An attachment reachable only from a page annotation, an
174
+ encrypted PDF, and a stream in a filter `pdf-lib` does not decode are not read: they
175
+ are refused with one of the codes above, never read wrongly. The XMP metadata is not read: the profile
176
+ comes from the XML.
177
+ - ZUGFeRD 1.0 (`CrossIndustryDocument`, another namespace) is refused as
178
+ `not_an_invoice`.
179
+ - Elements of EXTENDED beyond EN 16931 (several deliveries, line sub-structures…) are not
180
+ read. Nothing is validated against the CII schema or a Schematron.
181
+
106
182
  ## Development
107
183
 
108
184
  ```sh
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A received CII invoice or credit note, read.
3
+ *
4
+ * The XML of a Factur-X or ZUGFeRD file — UN/CEFACT Cross Industry Invoice
5
+ * D16B — turned into the plain shape of `received.ts`, which the UBL reader of
6
+ * the Peppol brick returns too: a business term is in the same field whichever
7
+ * syntax carried it. The reader follows the CII binding of EN 16931 (which
8
+ * business term lives in which element) and reads every profile of Factur-X
9
+ * 1.0 and ZUGFeRD 2.x, from MINIMUM to EXTENDED; `profile` says which one the
10
+ * file declares, and what a lighter profile does not carry comes back null or
11
+ * empty, never invented. EXTENDED elements beyond EN 16931 are not read.
12
+ *
13
+ * It reads what the file says and judges only the arithmetic (see
14
+ * `received-checks.ts`). A file that is not an invoice at all — not XML, not
15
+ * CII, no number, an amount that is not one — is an {@link InvoiceFileError},
16
+ * never a raw error of the parser.
17
+ */
18
+ import type { ReadOptions, ReceivedInvoiceFile } from './received.js';
19
+ import type { Profile } from './types.js';
20
+ /**
21
+ * The profile a guideline identifier declares. Another specification built on
22
+ * EN 16931 by restriction — a CIUS such as XRechnung, written
23
+ * `urn:cen.eu:en16931:2017#compliant#…` — is EN 16931 for a reader: it holds
24
+ * nothing EN 16931 does not. Anything else is null, and `customizationId`
25
+ * keeps what the file wrote.
26
+ */
27
+ export declare function profileOf(guideline: string | null): Profile | null;
28
+ /** What {@link readCii} returns: the invoice, what does not add up, and the profile the file declares. */
29
+ export interface ReceivedCiiFile extends ReceivedInvoiceFile {
30
+ /** From the guideline identifier (BT-24); null where it names no profile this reader knows. */
31
+ profile: Profile | null;
32
+ }
33
+ /**
34
+ * Reads the XML of a Factur-X or ZUGFeRD invoice: a CII D16B
35
+ * `CrossIndustryInvoice`, whatever its profile. To read it out of the PDF,
36
+ * `readFacturX` of `@ekwo-ai/factur-x/pdf`.
37
+ *
38
+ * Throws an {@link InvoiceFileError} when there is no invoice to return: the
39
+ * bytes are not XML or not UTF-8, the document is not CII, or it lacks a
40
+ * number, a type code, a date, a currency or its totals, or holds an amount
41
+ * or a date that is not one. Everything else comes back, with `violations`
42
+ * naming what does not add up.
43
+ *
44
+ * Whether the document is a credit note is said by its type code (BT-3)
45
+ * alone, as CII has a single root for both; the figures stay as written.
46
+ */
47
+ export declare function readCii(input: string | Uint8Array, options?: ReadOptions): ReceivedCiiFile;
48
+ //# sourceMappingURL=cii-read.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cii-read.d.ts","sourceRoot":"","sources":["../src/cii-read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,KAAK,EAEV,WAAW,EAOX,mBAAmB,EAMpB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAkC1C;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,GAAG,IAAI,CAKlE;AAED,0GAA0G;AAC1G,MAAM,WAAW,eAAgB,SAAQ,mBAAmB;IAC1D,+FAA+F;IAC/F,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;CACzB;AA+PD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,EAAE,OAAO,GAAE,WAAgB,GAAG,eAAe,CAqK9F"}
@@ -0,0 +1,462 @@
1
+ /**
2
+ * A received CII invoice or credit note, read.
3
+ *
4
+ * The XML of a Factur-X or ZUGFeRD file — UN/CEFACT Cross Industry Invoice
5
+ * D16B — turned into the plain shape of `received.ts`, which the UBL reader of
6
+ * the Peppol brick returns too: a business term is in the same field whichever
7
+ * syntax carried it. The reader follows the CII binding of EN 16931 (which
8
+ * business term lives in which element) and reads every profile of Factur-X
9
+ * 1.0 and ZUGFeRD 2.x, from MINIMUM to EXTENDED; `profile` says which one the
10
+ * file declares, and what a lighter profile does not carry comes back null or
11
+ * empty, never invented. EXTENDED elements beyond EN 16931 are not read.
12
+ *
13
+ * It reads what the file says and judges only the arithmetic (see
14
+ * `received-checks.ts`). A file that is not an invoice at all — not XML, not
15
+ * CII, no number, an amount that is not one — is an {@link InvoiceFileError},
16
+ * never a raw error of the parser.
17
+ */
18
+ import { InvoiceFileError } from './errors.js';
19
+ import { CREDIT_NOTE_TYPE_CODES, checkReceived, decimalText, decodeBase64, isValidIban } from './received-checks.js';
20
+ import { parseXml } from './xml.js';
21
+ const NS = {
22
+ rsm: 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
23
+ ram: 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
24
+ udt: 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100',
25
+ qdt: 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
26
+ ublInvoice: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
27
+ ublCreditNote: 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2',
28
+ };
29
+ const CII_NAMESPACES = new Set([NS.rsm, NS.ram, NS.udt, NS.qdt]);
30
+ const DEFAULTS = { maxBytes: 64 * 1024 * 1024, maxDepth: 64, maxElements: 500_000 };
31
+ /**
32
+ * The guideline identifiers (BT-24) of Factur-X 1.0 and ZUGFeRD 2.x, by
33
+ * profile, as the specifications of the two (FNFE-MPE and FeRD) list them.
34
+ * The `urn:zugferd.de:2p0` spellings are those of ZUGFeRD 2.0, written before
35
+ * the two specifications converged on the Factur-X ones.
36
+ */
37
+ const PROFILE_GUIDELINES = {
38
+ 'urn:factur-x.eu:1p0:minimum': 'minimum',
39
+ 'urn:zugferd.de:2p0:minimum': 'minimum',
40
+ 'urn:factur-x.eu:1p0:basicwl': 'basic-wl',
41
+ 'urn:zugferd.de:2p0:basicwl': 'basic-wl',
42
+ 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic': 'basic',
43
+ 'urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p0:basic': 'basic',
44
+ 'urn:cen.eu:en16931:2017': 'en16931',
45
+ 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended': 'extended',
46
+ 'urn:cen.eu:en16931:2017#conformant#urn:zugferd.de:2p0:extended': 'extended',
47
+ };
48
+ /**
49
+ * The profile a guideline identifier declares. Another specification built on
50
+ * EN 16931 by restriction — a CIUS such as XRechnung, written
51
+ * `urn:cen.eu:en16931:2017#compliant#…` — is EN 16931 for a reader: it holds
52
+ * nothing EN 16931 does not. Anything else is null, and `customizationId`
53
+ * keeps what the file wrote.
54
+ */
55
+ export function profileOf(guideline) {
56
+ if (guideline === null)
57
+ return null;
58
+ const known = PROFILE_GUIDELINES[guideline];
59
+ if (known !== undefined)
60
+ return known;
61
+ return guideline.startsWith('urn:cen.eu:en16931:2017#compliant#') ? 'en16931' : null;
62
+ }
63
+ function isCii(element, name) {
64
+ return element.name === name && CII_NAMESPACES.has(element.namespace);
65
+ }
66
+ function find(parent, path) {
67
+ let current = parent;
68
+ for (const name of path) {
69
+ if (current === undefined)
70
+ return undefined;
71
+ current = current.children.find((each) => isCii(each, name));
72
+ }
73
+ return current;
74
+ }
75
+ function all(parent, name) {
76
+ if (parent === undefined)
77
+ return [];
78
+ return parent.children.filter((each) => isCii(each, name));
79
+ }
80
+ /** The trimmed text at a path, or null when absent or blank. */
81
+ function text(parent, ...path) {
82
+ const value = find(parent, path)?.text.trim();
83
+ return value === undefined || value === '' ? null : value;
84
+ }
85
+ function attribute(element, name) {
86
+ const value = element?.attributes[name]?.trim();
87
+ return value === undefined || value === '' ? null : value;
88
+ }
89
+ function required(parent, what, ...path) {
90
+ const value = text(parent, ...path);
91
+ if (value === null)
92
+ throw new InvoiceFileError('missing_element', `the document has no ${what} (${path.join('/')})`);
93
+ return value;
94
+ }
95
+ function amount(parent, what, ...path) {
96
+ const value = text(parent, ...path);
97
+ return value === null ? null : decimalText(value, what);
98
+ }
99
+ function requiredAmount(parent, what, ...path) {
100
+ return decimalText(required(parent, what, ...path), what);
101
+ }
102
+ function identifier(element) {
103
+ const value = element?.text.trim();
104
+ if (element === undefined || value === undefined || value === '')
105
+ return null;
106
+ return { value, scheme: attribute(element, 'schemeID') };
107
+ }
108
+ const DAY_102 = /^([0-9]{4})([0-9]{2})([0-9]{2})$/;
109
+ /**
110
+ * One reading, holding the violations it finds on the way: a date in a format
111
+ * EN 16931 does not use is reported and returned as null rather than refused.
112
+ */
113
+ class Reader {
114
+ violations = [];
115
+ /**
116
+ * A date of CII: a `DateTimeString` (or `DateString`) of format 102,
117
+ * `YYYYMMDD` — the only format the CII binding of EN 16931 (EN 16931-3-3)
118
+ * uses for a date. `holder` is the element that contains the string,
119
+ * `IssueDateTime` and the like.
120
+ */
121
+ date(holder, what) {
122
+ if (holder === undefined)
123
+ return null;
124
+ const string = holder.children.find((each) => isCii(each, 'DateTimeString') || isCii(each, 'DateString'));
125
+ const raw = string?.text.trim() ?? '';
126
+ if (string === undefined || raw === '')
127
+ return null;
128
+ const format = attribute(string, 'format');
129
+ if (format !== null && format !== '102') {
130
+ this.violations.push({
131
+ code: 'unsupported_date_format',
132
+ message: `${what} is written in format ${format}; EN 16931 dates are format 102 (YYYYMMDD), and it was not read.`,
133
+ });
134
+ return null;
135
+ }
136
+ const match = DAY_102.exec(raw);
137
+ const year = Number(match?.[1]);
138
+ const month = Number(match?.[2]);
139
+ const day = Number(match?.[3]);
140
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
141
+ const length = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
142
+ if (match === null || length === undefined || day < 1 || day > length) {
143
+ throw new InvoiceFileError('invalid_value', `${what} is not a date: ${JSON.stringify(raw)}`);
144
+ }
145
+ return `${match[1]}-${match[2]}-${match[3]}`;
146
+ }
147
+ period(element, what) {
148
+ if (element === undefined)
149
+ return null;
150
+ return {
151
+ start: this.date(find(element, ['StartDateTime']), `the start of ${what}`),
152
+ end: this.date(find(element, ['EndDateTime']), `the end of ${what}`),
153
+ };
154
+ }
155
+ /** CII says which kind of account it holds; an IBAN whose digits fail is still the IBAN the file wrote, and reported. */
156
+ iban(value, what) {
157
+ if (value === null)
158
+ return null;
159
+ const compact = value.replace(/\s+/g, '').toUpperCase();
160
+ if (!isValidIban(compact)) {
161
+ this.violations.push({ code: 'invalid_iban', message: `${what}, ${value}, is written as an IBAN and its check digits fail.` });
162
+ }
163
+ return { kind: 'iban', value: compact };
164
+ }
165
+ }
166
+ // --- the parts -------------------------------------------------------------
167
+ function address(element) {
168
+ if (element === undefined)
169
+ return null;
170
+ const lines = [text(element, 'LineOne'), text(element, 'LineTwo'), text(element, 'LineThree')].filter((line) => line !== null);
171
+ const result = {
172
+ lines,
173
+ city: text(element, 'CityName'),
174
+ postalCode: text(element, 'PostcodeCode'),
175
+ region: text(element, 'CountrySubDivisionName'),
176
+ country: text(element, 'CountryID'),
177
+ };
178
+ return lines.length > 0 || Object.values(result).some((value) => typeof value === 'string') ? result : null;
179
+ }
180
+ function party(element, who) {
181
+ let vatId = null;
182
+ let taxRegistrationId = null;
183
+ for (const registration of all(element, 'SpecifiedTaxRegistration')) {
184
+ const id = find(registration, ['ID']);
185
+ const value = id?.text.trim();
186
+ if (value === undefined || value === '')
187
+ continue;
188
+ const scheme = (attribute(id, 'schemeID') ?? '').toUpperCase();
189
+ if (scheme === 'VA')
190
+ vatId ??= value;
191
+ else if (scheme === 'FC' && who === 'seller')
192
+ taxRegistrationId ??= value;
193
+ }
194
+ const legal = find(element, ['SpecifiedLegalOrganization']);
195
+ const contact = find(element, ['DefinedTradeContact']);
196
+ const contactFields = {
197
+ name: text(contact, 'PersonName') ?? text(contact, 'DepartmentName'),
198
+ phone: text(contact, 'TelephoneUniversalCommunication', 'CompleteNumber'),
199
+ email: text(contact, 'EmailURIUniversalCommunication', 'URIID'),
200
+ };
201
+ return {
202
+ name: text(element, 'Name'),
203
+ tradeName: text(legal, 'TradingBusinessName'),
204
+ // BT-29 / BT-46: GlobalID where the identifier has a scheme, ID where it has none.
205
+ identifiers: [...all(element, 'ID'), ...all(element, 'GlobalID')]
206
+ .map((each) => identifier(each))
207
+ .filter((each) => each !== null),
208
+ legalId: identifier(find(legal, ['ID'])),
209
+ vatId,
210
+ taxRegistrationId,
211
+ legalForm: who === 'seller' ? text(element, 'Description') : null,
212
+ electronicAddress: identifier(find(element, ['URIUniversalCommunication', 'URIID'])),
213
+ address: address(find(element, ['PostalTradeAddress'])),
214
+ contact: Object.values(contactFields).some((value) => value !== null) ? contactFields : null,
215
+ };
216
+ }
217
+ function allowanceCharge(element, where, onLine) {
218
+ const indicator = text(element, 'ChargeIndicator', 'Indicator');
219
+ if (indicator !== 'true' && indicator !== 'false') {
220
+ throw new InvoiceFileError('invalid_value', `an allowance or charge of ${where} says neither true nor false: ${JSON.stringify(indicator)}`);
221
+ }
222
+ const charge = indicator === 'true';
223
+ const what = `${charge ? 'a charge' : 'an allowance'} of ${where}`;
224
+ return {
225
+ charge,
226
+ amount: requiredAmount(element, `the amount of ${what}`, 'ActualAmount'),
227
+ baseAmount: amount(element, `the base of ${what}`, 'BasisAmount'),
228
+ percent: amount(element, `the percentage of ${what}`, 'CalculationPercent'),
229
+ reason: text(element, 'Reason'),
230
+ reasonCode: text(element, 'ReasonCode'),
231
+ vatCategory: onLine ? null : text(element, 'CategoryTradeTax', 'CategoryCode'),
232
+ vatRate: onLine ? null : amount(element, `the VAT rate of ${what}`, 'CategoryTradeTax', 'RateApplicablePercent'),
233
+ };
234
+ }
235
+ function line(reader, element, position) {
236
+ const document = find(element, ['AssociatedDocumentLineDocument']);
237
+ const id = text(document, 'LineID');
238
+ if (id === null)
239
+ throw new InvoiceFileError('missing_element', `line ${position} has no identifier (BT-126)`);
240
+ const where = `line ${id}`;
241
+ const product = find(element, ['SpecifiedTradeProduct']);
242
+ const agreement = find(element, ['SpecifiedLineTradeAgreement']);
243
+ const gross = find(agreement, ['GrossPriceProductTradePrice']);
244
+ const net = find(agreement, ['NetPriceProductTradePrice']);
245
+ const settlement = find(element, ['SpecifiedLineTradeSettlement']);
246
+ const tax = find(settlement, ['ApplicableTradeTax']);
247
+ const quantity = find(element, ['SpecifiedLineTradeDelivery', 'BilledQuantity']);
248
+ // BT-149 and BT-150 are written on the net price, and may be on the gross one.
249
+ const baseQuantity = find(net, ['BasisQuantity']) ?? find(gross, ['BasisQuantity']);
250
+ const summation = find(settlement, ['SpecifiedTradeSettlementLineMonetarySummation']);
251
+ return {
252
+ id,
253
+ note: text(document, 'IncludedNote', 'Content'),
254
+ quantity: quantity === undefined || quantity.text.trim() === '' ? null : decimalText(quantity.text, `the quantity of ${where}`),
255
+ unitCode: attribute(quantity, 'unitCode'),
256
+ netAmount: requiredAmount(summation, `the net amount of ${where}`, 'LineTotalAmount'),
257
+ orderLineReference: text(agreement, 'BuyerOrderReferencedDocument', 'LineID'),
258
+ period: reader.period(find(settlement, ['BillingSpecifiedPeriod']), where),
259
+ allowances: all(settlement, 'SpecifiedTradeAllowanceCharge').map((each) => allowanceCharge(each, where, true)),
260
+ netPrice: amount(net, `the net price of ${where}`, 'ChargeAmount'),
261
+ priceDiscount: amount(gross, `the price discount of ${where}`, 'AppliedTradeAllowanceCharge', 'ActualAmount'),
262
+ grossPrice: amount(gross, `the gross price of ${where}`, 'ChargeAmount'),
263
+ priceBaseQuantity: baseQuantity === undefined || baseQuantity.text.trim() === ''
264
+ ? null
265
+ : decimalText(baseQuantity.text, `the base quantity of the price of ${where}`),
266
+ priceBaseUnitCode: attribute(baseQuantity, 'unitCode'),
267
+ vatCategory: text(tax, 'CategoryCode'),
268
+ vatRate: amount(tax, `the VAT rate of ${where}`, 'RateApplicablePercent'),
269
+ name: text(product, 'Name'),
270
+ description: text(product, 'Description'),
271
+ sellerItemId: text(product, 'SellerAssignedID'),
272
+ buyerItemId: text(product, 'BuyerAssignedID'),
273
+ standardItemId: identifier(find(product, ['GlobalID'])),
274
+ };
275
+ }
276
+ function paymentMeans(reader, element, reference, mandateReference) {
277
+ const payee = find(element, ['PayeePartyCreditorFinancialAccount']);
278
+ const payeeIban = text(payee, 'IBANID');
279
+ const proprietary = text(payee, 'ProprietaryID');
280
+ return {
281
+ code: text(element, 'TypeCode'),
282
+ text: text(element, 'Information'),
283
+ reference,
284
+ account: payeeIban !== null
285
+ ? reader.iban(payeeIban, 'The account to pay')
286
+ : proprietary === null
287
+ ? null
288
+ : { kind: 'other', value: proprietary },
289
+ accountName: text(payee, 'AccountName'),
290
+ bic: text(element, 'PayeeSpecifiedCreditorFinancialInstitution', 'BICID'),
291
+ cardNumber: text(element, 'ApplicableTradeSettlementFinancialCard', 'ID'),
292
+ mandateReference,
293
+ debitedAccount: reader.iban(text(element, 'PayerPartyDebtorFinancialAccount', 'IBANID'), 'The account to debit'),
294
+ };
295
+ }
296
+ /**
297
+ * Reads the XML of a Factur-X or ZUGFeRD invoice: a CII D16B
298
+ * `CrossIndustryInvoice`, whatever its profile. To read it out of the PDF,
299
+ * `readFacturX` of `@ekwo-ai/factur-x/pdf`.
300
+ *
301
+ * Throws an {@link InvoiceFileError} when there is no invoice to return: the
302
+ * bytes are not XML or not UTF-8, the document is not CII, or it lacks a
303
+ * number, a type code, a date, a currency or its totals, or holds an amount
304
+ * or a date that is not one. Everything else comes back, with `violations`
305
+ * naming what does not add up.
306
+ *
307
+ * Whether the document is a credit note is said by its type code (BT-3)
308
+ * alone, as CII has a single root for both; the figures stay as written.
309
+ */
310
+ export function readCii(input, options = {}) {
311
+ const limits = { ...DEFAULTS, ...options };
312
+ const root = parseXml(input, limits);
313
+ if (root.namespace === NS.ublInvoice || root.namespace === NS.ublCreditNote) {
314
+ throw new InvoiceFileError('not_an_invoice', 'the file is a UBL invoice (Peppol), not CII: read it with the UBL reader');
315
+ }
316
+ if (root.name !== 'CrossIndustryInvoice' || root.namespace !== NS.rsm) {
317
+ throw new InvoiceFileError('not_an_invoice', `the document is <${root.name}> in ${root.namespace === '' ? 'no namespace' : root.namespace}, not a CII D16B CrossIndustryInvoice`);
318
+ }
319
+ const reader = new Reader();
320
+ const context = find(root, ['ExchangedDocumentContext']);
321
+ const exchanged = find(root, ['ExchangedDocument']);
322
+ if (exchanged === undefined)
323
+ throw new InvoiceFileError('missing_element', 'the document has no header (ExchangedDocument)');
324
+ const transaction = find(root, ['SupplyChainTradeTransaction']);
325
+ const agreement = find(transaction, ['ApplicableHeaderTradeAgreement']);
326
+ const delivery = find(transaction, ['ApplicableHeaderTradeDelivery']);
327
+ const settlement = find(transaction, ['ApplicableHeaderTradeSettlement']);
328
+ if (settlement === undefined) {
329
+ throw new InvoiceFileError('missing_element', 'the document has no settlement (ApplicableHeaderTradeSettlement)');
330
+ }
331
+ const guideline = text(context, 'GuidelineSpecifiedDocumentContextParameter', 'ID');
332
+ const typeCode = required(exchanged, 'type code (BT-3)', 'TypeCode');
333
+ const number = required(exchanged, 'number', 'ID');
334
+ const issueDate = reader.date(find(exchanged, ['IssueDateTime']), 'the issue date');
335
+ if (issueDate === null)
336
+ throw new InvoiceFileError('missing_element', 'the document has no issue date (IssueDateTime)');
337
+ const currency = required(settlement, 'currency', 'InvoiceCurrencyCode');
338
+ const taxCurrency = text(settlement, 'TaxCurrencyCode');
339
+ // BT-110 and BT-111 are both TaxTotalAmount, told apart by their currency.
340
+ const summation = find(settlement, ['SpecifiedTradeSettlementHeaderMonetarySummation']);
341
+ if (summation === undefined) {
342
+ throw new InvoiceFileError('missing_element', 'the document has no totals (SpecifiedTradeSettlementHeaderMonetarySummation)');
343
+ }
344
+ const taxTotals = all(summation, 'TaxTotalAmount');
345
+ const inCurrency = (each, code) => {
346
+ const written = attribute(each, 'currencyID');
347
+ return written === null ? code === currency : written === code;
348
+ };
349
+ const documentTax = taxTotals.find((each) => inCurrency(each, currency)) ?? (taxCurrency === null ? taxTotals[0] : undefined);
350
+ const taxCurrencyTax = taxCurrency !== null && taxCurrency !== currency
351
+ ? taxTotals.find((each) => each !== documentTax && inCurrency(each, taxCurrency))
352
+ : undefined;
353
+ const totalOf = (element, what) => element === undefined || element.text.trim() === '' ? null : decimalText(element.text, what);
354
+ const taxes = all(settlement, 'ApplicableTradeTax').map((group) => {
355
+ const label = `the VAT group ${text(group, 'CategoryCode') ?? '?'} ${text(group, 'RateApplicablePercent') ?? ''}`.trim();
356
+ return {
357
+ category: text(group, 'CategoryCode'),
358
+ rate: amount(group, `the rate of ${label}`, 'RateApplicablePercent'),
359
+ base: requiredAmount(group, `the base of ${label}`, 'BasisAmount'),
360
+ tax: requiredAmount(group, `the tax of ${label}`, 'CalculatedAmount'),
361
+ exemptionReasonCode: text(group, 'ExemptionReasonCode'),
362
+ exemptionReason: text(group, 'ExemptionReason'),
363
+ };
364
+ });
365
+ // BT-7 is written on the VAT breakdown in CII, once per group and the same in each.
366
+ const taxPointDate = all(settlement, 'ApplicableTradeTax')
367
+ .map((group) => reader.date(find(group, ['TaxPointDate']), 'the tax point date'))
368
+ .find((each) => each !== null) ?? null;
369
+ // BG-24 is an additional document of type 916; type 130 is an invoiced
370
+ // object (BT-18) and type 50 a tender or lot (BT-17), neither an attachment.
371
+ const attachments = [];
372
+ for (const reference of all(agreement, 'AdditionalReferencedDocument')) {
373
+ const documentType = text(reference, 'TypeCode');
374
+ if (documentType === '130' || documentType === '50')
375
+ continue;
376
+ const embedded = find(reference, ['AttachmentBinaryObject']);
377
+ let content = null;
378
+ if (embedded !== undefined) {
379
+ content = decodeBase64(embedded.text);
380
+ if (content === null) {
381
+ reader.violations.push({
382
+ code: 'invalid_attachment',
383
+ message: `The document ${text(reference, 'IssuerAssignedID') ?? 'attached'} is not base64, and was not decoded.`,
384
+ });
385
+ }
386
+ }
387
+ attachments.push({
388
+ id: text(reference, 'IssuerAssignedID'),
389
+ description: text(reference, 'Name'),
390
+ filename: attribute(embedded, 'filename'),
391
+ mimeType: attribute(embedded, 'mimeCode'),
392
+ content,
393
+ uri: text(reference, 'URIID'),
394
+ });
395
+ }
396
+ // BT-9, BT-20 and BT-89 are in the payment terms; BT-83 is one reference for
397
+ // the document. Each is returned on every means of payment, as UBL has them.
398
+ const terms = all(settlement, 'SpecifiedTradePaymentTerms');
399
+ const dueDate = terms.map((each) => reader.date(find(each, ['DueDateDateTime']), 'the due date')).find((each) => each !== null) ?? null;
400
+ const descriptions = terms.map((each) => text(each, 'Description')).filter((each) => each !== null);
401
+ const mandate = terms.map((each) => text(each, 'DirectDebitMandateID')).find((each) => each !== null) ?? null;
402
+ const paymentReference = text(settlement, 'PaymentReference');
403
+ const shipTo = find(delivery, ['ShipToTradeParty']);
404
+ const profile = profileOf(guideline);
405
+ const lineElements = all(transaction, 'IncludedSupplyChainTradeLineItem');
406
+ if (lineElements.length === 0 && profile !== 'minimum' && profile !== 'basic-wl') {
407
+ throw new InvoiceFileError('missing_element', 'the document has no line, and its profile is not one that carries none (MINIMUM, BASIC WL)');
408
+ }
409
+ const invoice = {
410
+ syntax: 'cii',
411
+ customizationId: guideline,
412
+ profileId: text(context, 'BusinessProcessSpecifiedDocumentContextParameter', 'ID'),
413
+ kind: CREDIT_NOTE_TYPE_CODES.has(typeCode) ? 'credit_note' : 'invoice',
414
+ typeCode,
415
+ number,
416
+ issueDate,
417
+ dueDate,
418
+ taxPointDate,
419
+ currency,
420
+ taxCurrency,
421
+ buyerReference: text(agreement, 'BuyerReference'),
422
+ orderReference: text(agreement, 'BuyerOrderReferencedDocument', 'IssuerAssignedID'),
423
+ salesOrderReference: text(agreement, 'SellerOrderReferencedDocument', 'IssuerAssignedID'),
424
+ contractReference: text(agreement, 'ContractReferencedDocument', 'IssuerAssignedID'),
425
+ projectReference: text(agreement, 'SpecifiedProcuringProject', 'ID'),
426
+ accountingCost: text(settlement, 'ReceivableSpecifiedTradeAccountingAccount', 'ID'),
427
+ precedingInvoices: all(settlement, 'InvoiceReferencedDocument')
428
+ .filter((each) => text(each, 'IssuerAssignedID') !== null)
429
+ .map((each) => ({
430
+ number: text(each, 'IssuerAssignedID'),
431
+ issueDate: reader.date(find(each, ['FormattedIssueDateTime']), 'the date of a preceding invoice'),
432
+ })),
433
+ notes: all(exchanged, 'IncludedNote')
434
+ .map((each) => text(each, 'Content'))
435
+ .filter((each) => each !== null),
436
+ seller: party(find(agreement, ['SellerTradeParty']), 'seller'),
437
+ buyer: party(find(agreement, ['BuyerTradeParty']), 'buyer'),
438
+ deliveryDate: reader.date(find(delivery, ['ActualDeliverySupplyChainEvent', 'OccurrenceDateTime']), 'the delivery date'),
439
+ deliveryAddress: address(find(shipTo, ['PostalTradeAddress'])),
440
+ invoicingPeriod: reader.period(find(settlement, ['BillingSpecifiedPeriod']), 'the invoicing period'),
441
+ paymentTerms: descriptions.length === 0 ? null : descriptions.join('\n'),
442
+ paymentMeans: all(settlement, 'SpecifiedTradeSettlementPaymentMeans').map((each) => paymentMeans(reader, each, paymentReference, mandate)),
443
+ allowances: all(settlement, 'SpecifiedTradeAllowanceCharge').map((each) => allowanceCharge(each, 'the document', false)),
444
+ taxes,
445
+ totals: {
446
+ lineTotal: amount(summation, 'the sum of the lines', 'LineTotalAmount'),
447
+ allowanceTotal: amount(summation, 'the sum of the allowances', 'AllowanceTotalAmount'),
448
+ chargeTotal: amount(summation, 'the sum of the charges', 'ChargeTotalAmount'),
449
+ taxExclusive: requiredAmount(summation, 'the total without VAT', 'TaxBasisTotalAmount'),
450
+ taxTotal: totalOf(documentTax, 'the total VAT'),
451
+ taxTotalInTaxCurrency: totalOf(taxCurrencyTax, 'the total VAT in the tax currency'),
452
+ taxInclusive: requiredAmount(summation, 'the total with VAT', 'GrandTotalAmount'),
453
+ prepaid: amount(summation, 'the amount paid', 'TotalPrepaidAmount'),
454
+ rounding: amount(summation, 'the rounding amount', 'RoundingAmount'),
455
+ payable: requiredAmount(summation, 'the amount due', 'DuePayableAmount'),
456
+ },
457
+ lines: lineElements.map((each, index) => line(reader, each, index + 1)),
458
+ attachments,
459
+ };
460
+ return { invoice, violations: [...reader.violations, ...checkReceived(invoice)], profile };
461
+ }
462
+ //# sourceMappingURL=cii-read.js.map