@stackforge-eu/factur-x 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] — 2026-09-01
11
+
12
+ - **Added item price base quantity (BT-149/BT-150).** New optional
13
+ `basisQuantity` and `basisQuantityUnitCode` fields on `InvoiceLineInput`
14
+ emit `ram:BasisQuantity` inside the net (and, when present, gross) price
15
+ element, so a unit price can state the quantity it refers to — e.g.
16
+ `12.50` per 10 m². The unit code defaults to the line's `unitCode`, as
17
+ EN 16931 requires BT-150 to match BT-130. Omitting the field keeps the
18
+ output byte-for-byte identical (#12).
19
+ - CI now requires every PR to add a `CHANGELOG.md` entry (or carry the
20
+ `skip-changelog` label), so contributor fixes always reach the release notes.
21
+
22
+ ## [1.2.1] — 2026-08-27
23
+
24
+ - **Fixed XSD validation on Windows.** Schema `<xsd:import>` resolution built a
25
+ malformed `file://` URL from backslash paths and keyed the schema lookup by
26
+ a percent-encoded href that libxml2 normalises differently. Now uses
27
+ `pathToFileURL()` and matches imported schemas by filename. Windows added
28
+ to the CI matrix (thanks @muratkorkmaztr-cmyk, #6).
29
+ - **Embedded `factur-x.xml` now uses the `application/xml` MIME type** instead
30
+ of the deprecated `text/xml` (RFC 7303), which current Factur-X conformance
31
+ checkers flag (thanks @muratkorkmaztr-cmyk, #7).
32
+ - Expanded npm `keywords` (e.g. `e-rechnung`, `pdfa`, `chorus-pro`, `un-cefact`,
33
+ spelling variants) so the package is easier to find on npm (#8).
34
+
10
35
  ## [1.2.0] — 2026-07-11
11
36
 
12
37
  - **Updated to Factur-X 1.09 / ZUGFeRD 2.5** (spec dated 2026-06-10). Factur-X
@@ -70,12 +95,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
70
95
  `ram:GlobalID` had the same defect as the party one)
71
96
  - `LegalOrganizationInput.schemeID?: string` added (optional, mirrors the
72
97
  conditional spec rule)
73
- Migration: `globalId: "4000001000005"` →
74
- `globalId: { value: "4000001000005", schemeID: "0088" }` (and the analogous
75
- rewrite for `standardIdentifier`). The new `IdentifierWithSchemeInput` type
76
- is exported from `src/index.ts`. Regression test added in
77
- `tests/xsd-validator.test.ts` that drives all three schemed identifiers
78
- through the EN16931 XSD in one go (thanks @sco-indy, #4).
98
+ Migration: `globalId: "4000001000005"` →
99
+ `globalId: { value: "4000001000005", schemeID: "0088" }` (and the analogous
100
+ rewrite for `standardIdentifier`). The new `IdentifierWithSchemeInput` type
101
+ is exported from `src/index.ts`. Regression test added in
102
+ `tests/xsd-validator.test.ts` that drives all three schemed identifiers
103
+ through the EN16931 XSD in one go (thanks @sco-indy, #4).
79
104
 
80
105
  ## [1.0.8] — 2026-05-27
81
106
 
@@ -122,7 +147,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
122
147
  debtor account last. Any invoice combining a SEPA direct debit
123
148
  (`payment.meansCode = "59"`) with `payment.debtorIban` plus a payee
124
149
  IBAN/BIC failed XSD validation with `Element 'PayerPartyDebtorFinancialAccount':
125
- This element is not expected`. Reordered the three blocks in
150
+ This element is not expected`. Reordered the three blocks in
126
151
  `src/core/xml-builder.ts`; regression test added in
127
152
  `tests/xml-builder.test.ts`.
128
153
 
@@ -199,6 +224,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
199
224
  - Deno compatibility test suite
200
225
 
201
226
  [Unreleased]: https://github.com/StackForge-EU/factur-x/commits/main
227
+ [1.3.0]: https://github.com/StackForge-EU/factur-x/compare/v1.2.1...v1.3.0
228
+ [1.2.1]: https://github.com/StackForge-EU/factur-x/compare/v1.2.0...v1.2.1
202
229
  [1.2.0]: https://github.com/StackForge-EU/factur-x/compare/v1.1.0...v1.2.0
203
230
  [1.1.0]: https://github.com/StackForge-EU/factur-x/compare/v1.0.8...v1.1.0
204
231
  [1.0.8]: https://github.com/StackForge-EU/factur-x/compare/v1.0.7...v1.0.8
package/dist/index.cjs CHANGED
@@ -402,13 +402,19 @@ function buildLineItem(line, profile) {
402
402
  "ram:BuyerOrderReferencedDocument",
403
403
  tag("ram:LineID", escapeXml(line.buyerOrderLineId))
404
404
  );
405
+ const unitCode = line.unitCode ?? "C62" /* UNIT */;
406
+ const basisQuantity = line.basisQuantity !== void 0 ? tag("ram:BasisQuantity", line.basisQuantity.toString(), {
407
+ unitCode: line.basisQuantityUnitCode ?? unitCode
408
+ }) : "";
405
409
  if (line.grossUnitPrice !== void 0)
406
410
  agree += tag(
407
411
  "ram:GrossPriceProductTradePrice",
408
- tag("ram:ChargeAmount", fmtAmt(line.grossUnitPrice))
412
+ tag("ram:ChargeAmount", fmtAmt(line.grossUnitPrice)) + basisQuantity
409
413
  );
410
- agree += tag("ram:NetPriceProductTradePrice", tag("ram:ChargeAmount", fmtAmt(line.unitPrice)));
411
- const unitCode = line.unitCode ?? "C62" /* UNIT */;
414
+ agree += tag(
415
+ "ram:NetPriceProductTradePrice",
416
+ tag("ram:ChargeAmount", fmtAmt(line.unitPrice)) + basisQuantity
417
+ );
412
418
  const delivery = tag("ram:BilledQuantity", line.quantity.toString(), {
413
419
  unitCode
414
420
  });
@@ -509,10 +515,7 @@ function buildXml(input, profile, flavor) {
509
515
  tag("ram:ID", escapeXml(doc.businessProcessId))
510
516
  );
511
517
  const profileUrn = fl === "xrechnung" /* XRECHNUNG */ ? XRECHNUNG_PROFILE_URN : PROFILE_URNS[profile];
512
- ctx += tag(
513
- "ram:GuidelineSpecifiedDocumentContextParameter",
514
- tag("ram:ID", profileUrn)
515
- );
518
+ ctx += tag("ram:GuidelineSpecifiedDocumentContextParameter", tag("ram:ID", profileUrn));
516
519
  let edoc = tag("ram:ID", escapeXml(doc.id));
517
520
  if (doc.name && atLeast(profile, "EXTENDED" /* EXTENDED */)) edoc += tag("ram:Name", escapeXml(doc.name));
518
521
  edoc += tag("ram:TypeCode", typeCode);
@@ -1100,26 +1103,42 @@ async function validateXsd(xml, profile, options) {
1100
1103
  XmlValidateError,
1101
1104
  xmlRegisterInputProvider,
1102
1105
  xmlCleanupInputProvider,
1103
- XmlBufferInputProvider
1106
+ openBuffer,
1107
+ readBuffer,
1108
+ closeBuffer
1104
1109
  } = await import('libxml2-wasm');
1105
1110
  const schemaDir = path__namespace.dirname(xsdPath);
1106
1111
  const files = fs__namespace.readdirSync(schemaDir).filter((f) => f.endsWith(".xsd"));
1107
1112
  const buffers = {};
1108
1113
  for (const file of files) {
1109
- const filePath = path__namespace.join(schemaDir, file);
1110
- buffers[filePath] = fs__namespace.readFileSync(filePath);
1111
- buffers[`file://${filePath}`] = buffers[filePath];
1112
- buffers[file] = buffers[filePath];
1113
- }
1114
+ buffers[file] = fs__namespace.readFileSync(path__namespace.join(schemaDir, file));
1115
+ }
1116
+ const schemaFileFor = (requested) => {
1117
+ const start = Math.max(requested.lastIndexOf("/"), requested.lastIndexOf("\\")) + 1;
1118
+ const segment = requested.slice(start);
1119
+ let name = segment;
1120
+ try {
1121
+ name = decodeURIComponent(segment);
1122
+ } catch {
1123
+ }
1124
+ return files.includes(name) ? name : void 0;
1125
+ };
1114
1126
  xmlCleanupInputProvider();
1115
- const provider = new XmlBufferInputProvider(buffers);
1116
- xmlRegisterInputProvider(provider);
1127
+ xmlRegisterInputProvider({
1128
+ match: (filename) => schemaFileFor(filename) !== void 0,
1129
+ open: (filename) => openBuffer(buffers[schemaFileFor(filename)]),
1130
+ read: readBuffer,
1131
+ close: (fd) => {
1132
+ closeBuffer(fd);
1133
+ return true;
1134
+ }
1135
+ });
1117
1136
  let xsdDoc;
1118
1137
  let validator;
1119
1138
  let xmlDoc;
1120
1139
  try {
1121
1140
  const xsdContent = fs__namespace.readFileSync(xsdPath, "utf-8");
1122
- xsdDoc = XmlDocument.fromString(xsdContent, { url: `file://${xsdPath}` });
1141
+ xsdDoc = XmlDocument.fromString(xsdContent, { url: url.pathToFileURL(xsdPath).href });
1123
1142
  validator = XsdValidator.fromDoc(xsdDoc);
1124
1143
  xmlDoc = XmlDocument.fromString(xml);
1125
1144
  validator.validate(xmlDoc);
@@ -1162,7 +1181,9 @@ function resolveXmpConformanceLevel(profile, flavor) {
1162
1181
  }
1163
1182
  function ensureIdTrailer(pdfDoc, pdfContentBytes) {
1164
1183
  const existingIdTrailer = pdfDoc.context.trailerInfo.ID;
1165
- const currentContentHash = pdfLib.PDFHexString.of(crypto.createHash("md5").update(pdfContentBytes).digest("hex"));
1184
+ const currentContentHash = pdfLib.PDFHexString.of(
1185
+ crypto.createHash("md5").update(pdfContentBytes).digest("hex")
1186
+ );
1166
1187
  const hasValidIdTrailer = existingIdTrailer instanceof pdfLib.PDFArray && existingIdTrailer.size() === 2;
1167
1188
  if (hasValidIdTrailer) {
1168
1189
  const existingFirstId = existingIdTrailer.lookup(0);
@@ -1191,11 +1212,7 @@ function ensureOutputIntent(pdfDoc, iccBytes, identifier) {
1191
1212
  function findUnembeddedFonts(pdfDoc) {
1192
1213
  const seen = /* @__PURE__ */ new Set();
1193
1214
  const unembedded = /* @__PURE__ */ new Set();
1194
- const FONT_FILE_KEYS = [
1195
- pdfLib.PDFName.of("FontFile"),
1196
- pdfLib.PDFName.of("FontFile2"),
1197
- pdfLib.PDFName.of("FontFile3")
1198
- ];
1215
+ const FONT_FILE_KEYS = [pdfLib.PDFName.of("FontFile"), pdfLib.PDFName.of("FontFile2"), pdfLib.PDFName.of("FontFile3")];
1199
1216
  const inspectFontDict = (font) => {
1200
1217
  const subtype = font.lookup(pdfLib.PDFName.of("Subtype"));
1201
1218
  const baseFont = font.lookup(pdfLib.PDFName.of("BaseFont"));
@@ -1391,7 +1408,7 @@ async function embedFacturX(options) {
1391
1408
  const xmlBytes = new TextEncoder().encode(xml);
1392
1409
  const afRel = resolveAfRelationship(profile, options.afRelationship);
1393
1410
  await pdfDoc.attach(xmlBytes, flavorConfig.attachmentFilename, {
1394
- mimeType: "text/xml",
1411
+ mimeType: "application/xml",
1395
1412
  afRelationship: afRel,
1396
1413
  description: "Factur-X Invoice"
1397
1414
  });
package/dist/index.d.cts CHANGED
@@ -573,6 +573,20 @@ interface InvoiceLineInput {
573
573
  * @see EN 16931 BT-148
574
574
  */
575
575
  grossUnitPrice?: number;
576
+ /**
577
+ * Item price base quantity — the quantity the unit price (BT-146)
578
+ * applies to, e.g. a price per 10 square metres or per 1000 prints.
579
+ * @see EN 16931 BT-149
580
+ */
581
+ basisQuantity?: number;
582
+ /**
583
+ * Unit of measure for `basisQuantity` (UN/CEFACT Rec 20).
584
+ *
585
+ * @default the line's `unitCode` when omitted, as EN 16931 requires
586
+ * BT-150 to match the invoiced quantity's unit (BT-130)
587
+ * @see EN 16931 BT-150
588
+ */
589
+ basisQuantityUnitCode?: UnitCode | (string & Record<never, never>);
576
590
  /**
577
591
  * Line total (net) — can be derived from `quantity * unitPrice`.
578
592
  * @see EN 16931 BT-131
package/dist/index.d.ts CHANGED
@@ -573,6 +573,20 @@ interface InvoiceLineInput {
573
573
  * @see EN 16931 BT-148
574
574
  */
575
575
  grossUnitPrice?: number;
576
+ /**
577
+ * Item price base quantity — the quantity the unit price (BT-146)
578
+ * applies to, e.g. a price per 10 square metres or per 1000 prints.
579
+ * @see EN 16931 BT-149
580
+ */
581
+ basisQuantity?: number;
582
+ /**
583
+ * Unit of measure for `basisQuantity` (UN/CEFACT Rec 20).
584
+ *
585
+ * @default the line's `unitCode` when omitted, as EN 16931 requires
586
+ * BT-150 to match the invoiced quantity's unit (BT-130)
587
+ * @see EN 16931 BT-150
588
+ */
589
+ basisQuantityUnitCode?: UnitCode | (string & Record<never, never>);
576
590
  /**
577
591
  * Line total (net) — can be derived from `quantity * unitPrice`.
578
592
  * @see EN 16931 BT-131
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { fileURLToPath } from 'url';
3
+ import { pathToFileURL, fileURLToPath } from 'url';
4
4
  import console from 'console';
5
5
  import { createHash } from 'crypto';
6
6
  import { AFRelationship, PDFDocument, PDFName, PDFArray, PDFDict, PDFStream, PDFRawStream, decodePDFRawStream, PDFString, PDFHexString, PDFRef } from 'pdf-lib';
@@ -372,13 +372,19 @@ function buildLineItem(line, profile) {
372
372
  "ram:BuyerOrderReferencedDocument",
373
373
  tag("ram:LineID", escapeXml(line.buyerOrderLineId))
374
374
  );
375
+ const unitCode = line.unitCode ?? "C62" /* UNIT */;
376
+ const basisQuantity = line.basisQuantity !== void 0 ? tag("ram:BasisQuantity", line.basisQuantity.toString(), {
377
+ unitCode: line.basisQuantityUnitCode ?? unitCode
378
+ }) : "";
375
379
  if (line.grossUnitPrice !== void 0)
376
380
  agree += tag(
377
381
  "ram:GrossPriceProductTradePrice",
378
- tag("ram:ChargeAmount", fmtAmt(line.grossUnitPrice))
382
+ tag("ram:ChargeAmount", fmtAmt(line.grossUnitPrice)) + basisQuantity
379
383
  );
380
- agree += tag("ram:NetPriceProductTradePrice", tag("ram:ChargeAmount", fmtAmt(line.unitPrice)));
381
- const unitCode = line.unitCode ?? "C62" /* UNIT */;
384
+ agree += tag(
385
+ "ram:NetPriceProductTradePrice",
386
+ tag("ram:ChargeAmount", fmtAmt(line.unitPrice)) + basisQuantity
387
+ );
382
388
  const delivery = tag("ram:BilledQuantity", line.quantity.toString(), {
383
389
  unitCode
384
390
  });
@@ -479,10 +485,7 @@ function buildXml(input, profile, flavor) {
479
485
  tag("ram:ID", escapeXml(doc.businessProcessId))
480
486
  );
481
487
  const profileUrn = fl === "xrechnung" /* XRECHNUNG */ ? XRECHNUNG_PROFILE_URN : PROFILE_URNS[profile];
482
- ctx += tag(
483
- "ram:GuidelineSpecifiedDocumentContextParameter",
484
- tag("ram:ID", profileUrn)
485
- );
488
+ ctx += tag("ram:GuidelineSpecifiedDocumentContextParameter", tag("ram:ID", profileUrn));
486
489
  let edoc = tag("ram:ID", escapeXml(doc.id));
487
490
  if (doc.name && atLeast(profile, "EXTENDED" /* EXTENDED */)) edoc += tag("ram:Name", escapeXml(doc.name));
488
491
  edoc += tag("ram:TypeCode", typeCode);
@@ -1070,26 +1073,42 @@ async function validateXsd(xml, profile, options) {
1070
1073
  XmlValidateError,
1071
1074
  xmlRegisterInputProvider,
1072
1075
  xmlCleanupInputProvider,
1073
- XmlBufferInputProvider
1076
+ openBuffer,
1077
+ readBuffer,
1078
+ closeBuffer
1074
1079
  } = await import('libxml2-wasm');
1075
1080
  const schemaDir = path.dirname(xsdPath);
1076
1081
  const files = fs.readdirSync(schemaDir).filter((f) => f.endsWith(".xsd"));
1077
1082
  const buffers = {};
1078
1083
  for (const file of files) {
1079
- const filePath = path.join(schemaDir, file);
1080
- buffers[filePath] = fs.readFileSync(filePath);
1081
- buffers[`file://${filePath}`] = buffers[filePath];
1082
- buffers[file] = buffers[filePath];
1083
- }
1084
+ buffers[file] = fs.readFileSync(path.join(schemaDir, file));
1085
+ }
1086
+ const schemaFileFor = (requested) => {
1087
+ const start = Math.max(requested.lastIndexOf("/"), requested.lastIndexOf("\\")) + 1;
1088
+ const segment = requested.slice(start);
1089
+ let name = segment;
1090
+ try {
1091
+ name = decodeURIComponent(segment);
1092
+ } catch {
1093
+ }
1094
+ return files.includes(name) ? name : void 0;
1095
+ };
1084
1096
  xmlCleanupInputProvider();
1085
- const provider = new XmlBufferInputProvider(buffers);
1086
- xmlRegisterInputProvider(provider);
1097
+ xmlRegisterInputProvider({
1098
+ match: (filename) => schemaFileFor(filename) !== void 0,
1099
+ open: (filename) => openBuffer(buffers[schemaFileFor(filename)]),
1100
+ read: readBuffer,
1101
+ close: (fd) => {
1102
+ closeBuffer(fd);
1103
+ return true;
1104
+ }
1105
+ });
1087
1106
  let xsdDoc;
1088
1107
  let validator;
1089
1108
  let xmlDoc;
1090
1109
  try {
1091
1110
  const xsdContent = fs.readFileSync(xsdPath, "utf-8");
1092
- xsdDoc = XmlDocument.fromString(xsdContent, { url: `file://${xsdPath}` });
1111
+ xsdDoc = XmlDocument.fromString(xsdContent, { url: pathToFileURL(xsdPath).href });
1093
1112
  validator = XsdValidator.fromDoc(xsdDoc);
1094
1113
  xmlDoc = XmlDocument.fromString(xml);
1095
1114
  validator.validate(xmlDoc);
@@ -1132,7 +1151,9 @@ function resolveXmpConformanceLevel(profile, flavor) {
1132
1151
  }
1133
1152
  function ensureIdTrailer(pdfDoc, pdfContentBytes) {
1134
1153
  const existingIdTrailer = pdfDoc.context.trailerInfo.ID;
1135
- const currentContentHash = PDFHexString.of(createHash("md5").update(pdfContentBytes).digest("hex"));
1154
+ const currentContentHash = PDFHexString.of(
1155
+ createHash("md5").update(pdfContentBytes).digest("hex")
1156
+ );
1136
1157
  const hasValidIdTrailer = existingIdTrailer instanceof PDFArray && existingIdTrailer.size() === 2;
1137
1158
  if (hasValidIdTrailer) {
1138
1159
  const existingFirstId = existingIdTrailer.lookup(0);
@@ -1161,11 +1182,7 @@ function ensureOutputIntent(pdfDoc, iccBytes, identifier) {
1161
1182
  function findUnembeddedFonts(pdfDoc) {
1162
1183
  const seen = /* @__PURE__ */ new Set();
1163
1184
  const unembedded = /* @__PURE__ */ new Set();
1164
- const FONT_FILE_KEYS = [
1165
- PDFName.of("FontFile"),
1166
- PDFName.of("FontFile2"),
1167
- PDFName.of("FontFile3")
1168
- ];
1185
+ const FONT_FILE_KEYS = [PDFName.of("FontFile"), PDFName.of("FontFile2"), PDFName.of("FontFile3")];
1169
1186
  const inspectFontDict = (font) => {
1170
1187
  const subtype = font.lookup(PDFName.of("Subtype"));
1171
1188
  const baseFont = font.lookup(PDFName.of("BaseFont"));
@@ -1361,7 +1378,7 @@ async function embedFacturX(options) {
1361
1378
  const xmlBytes = new TextEncoder().encode(xml);
1362
1379
  const afRel = resolveAfRelationship(profile, options.afRelationship);
1363
1380
  await pdfDoc.attach(xmlBytes, flavorConfig.attachmentFilename, {
1364
- mimeType: "text/xml",
1381
+ mimeType: "application/xml",
1365
1382
  afRelationship: afRel,
1366
1383
  description: "Factur-X Invoice"
1367
1384
  });
package/package.json CHANGED
@@ -1,21 +1,39 @@
1
1
  {
2
2
  "name": "@stackforge-eu/factur-x",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Embed Factur-X, ZUGFeRD, and XRechnung structured invoice XML into PDF/A-3 documents",
6
6
  "keywords": [
7
7
  "factur-x",
8
+ "facturx",
8
9
  "zugferd",
9
10
  "xrechnung",
11
+ "e-rechnung",
12
+ "chorus-pro",
10
13
  "en16931",
14
+ "en-16931",
11
15
  "cii",
16
+ "un-cefact",
17
+ "cross-industry-invoice",
12
18
  "pdf",
19
+ "pdfa",
20
+ "pdf-a",
13
21
  "pdf-a3",
22
+ "pdfa3",
23
+ "xmp",
14
24
  "invoice",
15
25
  "e-invoice",
16
26
  "e-invoicing",
27
+ "einvoice",
28
+ "e-invoice-eu",
29
+ "electronic-invoice",
30
+ "b2g",
17
31
  "eu",
18
- "xml"
32
+ "germany",
33
+ "france",
34
+ "xml",
35
+ "typescript",
36
+ "deno"
19
37
  ],
20
38
  "license": "EUPL-1.2",
21
39
  "author": "StackForge UG",