@stackforge-eu/factur-x 1.4.3 → 1.4.5

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,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.5] — 2026-11-14
11
+
12
+ - **Fixed derived line net amount ignoring `basisQuantity`.** When a line
13
+ omits `lineTotal`, `buildXml` and the BR-CO-10 check in `validateInput`
14
+ computed it as `quantity * unitPrice`, so a price per 100 units produced a
15
+ line total 100× too high and a correct `totals.lineTotal` was rejected. The
16
+ amount is now `quantity * unitPrice / basisQuantity` (EN 16931 BT-131),
17
+ rounded per line in both places. Reported by @trackoor (#26).
18
+ - **`validateInput` rejects a `basisQuantity` that is not greater than zero.**
19
+
20
+ ## [1.4.4] — 2026-11-09
21
+
22
+ - **Fixed header reference element order.** `buildXml` emitted
23
+ `input.references` in input order, so an `order` reference listed before a
24
+ `seller-order` (or `contract`) reference produced XML that failed XSD
25
+ validation. References are now emitted in the CII sequence (SellerOrder →
26
+ BuyerOrder → Contract → SpecifiedProcuringProject); input order is only
27
+ kept among references of the same type. Reported by @SCPAU100 (#23).
28
+ - **Fixed `project` references failing XSD validation.** `SpecifiedProcuringProject`
29
+ requires a `ram:Name` child that was never emitted. A new optional `name`
30
+ field on `DocumentReferenceInput` fills it, defaulting to `"Project"`.
31
+ - **`validateInput` rejects duplicate single-occurrence references.** The
32
+ schema allows `order`, `contract`, `seller-order`, `project` and `despatch`
33
+ references at most once each, and `delivery.despatchAdviceReference` and a
34
+ `despatch` reference target the same element. Both cases used to surface
35
+ only as an XSD error after building.
36
+
10
37
  ## [1.4.3] — 2026-09-28
11
38
 
12
39
  - **Fixed half-cent rounding.** `Math.round(n * 100) / 100` turns
@@ -274,6 +301,8 @@ This element is not expected`. Reordered the three blocks in
274
301
  - Deno compatibility test suite
275
302
 
276
303
  [Unreleased]: https://github.com/StackForge-EU/factur-x/commits/main
304
+ [1.4.5]: https://github.com/StackForge-EU/factur-x/compare/v1.4.4...v1.4.5
305
+ [1.4.4]: https://github.com/StackForge-EU/factur-x/compare/v1.4.3...v1.4.4
277
306
  [1.4.3]: https://github.com/StackForge-EU/factur-x/compare/v1.4.2...v1.4.3
278
307
  [1.4.2]: https://github.com/StackForge-EU/factur-x/compare/v1.4.1...v1.4.2
279
308
  [1.4.1]: https://github.com/StackForge-EU/factur-x/compare/v1.4.0...v1.4.1
package/dist/index.cjs CHANGED
@@ -289,6 +289,9 @@ function toCents(n) {
289
289
  function roundHalfUp(n) {
290
290
  return toCents(n) / 100;
291
291
  }
292
+ function deriveLineNet(line) {
293
+ return roundHalfUp(line.quantity * line.unitPrice / (line.basisQuantity ?? 1));
294
+ }
292
295
 
293
296
  // src/core/xml-builder.ts
294
297
  var PROFILE_LEVEL = {
@@ -451,7 +454,7 @@ function buildLineItem(line, profile) {
451
454
  tx += tag("ram:RateApplicablePercent", line.vatRatePercent.toString());
452
455
  settle += tag("ram:ApplicableTradeTax", tx);
453
456
  }
454
- const lineTotal = line.lineTotal ?? roundHalfUp(line.quantity * line.unitPrice);
457
+ const lineTotal = line.lineTotal ?? deriveLineNet(line);
455
458
  settle += tag(
456
459
  "ram:SpecifiedTradeSettlementLineMonetarySummation",
457
460
  tag("ram:LineTotalAmount", fmtAmt(lineTotal))
@@ -570,27 +573,21 @@ function buildXml(input, profile, flavor) {
570
573
  buildParty(input.sellerTaxRepresentative, profile)
571
574
  );
572
575
  if (input.references && atLeast(profile, "BASIC_WL" /* BASIC_WL */)) {
573
- for (const ref of input.references) {
574
- const refType = ref.type ?? "order";
575
- if (refType === "preceding" || refType === "despatch") continue;
576
- const inner = tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
577
- switch (refType) {
578
- case "order":
579
- agree += tag("ram:BuyerOrderReferencedDocument", inner);
580
- break;
581
- case "contract":
582
- agree += tag("ram:ContractReferencedDocument", inner);
583
- break;
584
- case "seller-order":
585
- if (atLeast(profile, "EN16931" /* EN16931 */))
586
- agree += tag("ram:SellerOrderReferencedDocument", inner);
587
- break;
588
- case "project":
589
- if (atLeast(profile, "EN16931" /* EN16931 */))
590
- agree += tag("ram:SpecifiedProcuringProject", tag("ram:ID", escapeXml(ref.id)));
591
- break;
592
- }
593
- }
576
+ const refsOfType = (type) => input.references.filter((ref) => (ref.type ?? "order") === type);
577
+ const refDoc = (ref) => tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
578
+ if (atLeast(profile, "EN16931" /* EN16931 */))
579
+ for (const ref of refsOfType("seller-order"))
580
+ agree += tag("ram:SellerOrderReferencedDocument", refDoc(ref));
581
+ for (const ref of refsOfType("order"))
582
+ agree += tag("ram:BuyerOrderReferencedDocument", refDoc(ref));
583
+ for (const ref of refsOfType("contract"))
584
+ agree += tag("ram:ContractReferencedDocument", refDoc(ref));
585
+ if (atLeast(profile, "EN16931" /* EN16931 */))
586
+ for (const ref of refsOfType("project"))
587
+ agree += tag(
588
+ "ram:SpecifiedProcuringProject",
589
+ tag("ram:ID", escapeXml(ref.id)) + tag("ram:Name", escapeXml(ref.name ?? "Project"))
590
+ );
594
591
  }
595
592
  tx += tag("ram:ApplicableHeaderTradeAgreement", agree);
596
593
  let del = "";
@@ -885,6 +882,14 @@ function validateInput(input, profile, flavor) {
885
882
  if (line.unitPrice == null) {
886
883
  addError(errors, `lines[${i}].unitPrice`, "Line unit price is required", "BASIC" /* BASIC */);
887
884
  }
885
+ if (line.basisQuantity != null && !(line.basisQuantity > 0)) {
886
+ addError(
887
+ errors,
888
+ `lines[${i}].basisQuantity`,
889
+ "Line price base quantity (BT-149) must be greater than zero",
890
+ "BASIC" /* BASIC */
891
+ );
892
+ }
888
893
  });
889
894
  }
890
895
  }
@@ -981,8 +986,8 @@ function validateInput(input, profile, flavor) {
981
986
  }
982
987
  if (req("BASIC" /* BASIC */) && input.totals && input.lines) {
983
988
  const sumLineNets = input.lines.reduce((sum, l) => {
984
- const lineNet = l.lineTotal ?? (typeof l.quantity === "number" && typeof l.unitPrice === "number" ? l.quantity * l.unitPrice : 0);
985
- return sum + lineNet;
989
+ const lineNet = l.lineTotal ?? deriveLineNet(l);
990
+ return sum + (Number.isFinite(lineNet) ? lineNet : 0);
986
991
  }, 0);
987
992
  if (input.totals.lineTotal != null) {
988
993
  if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
@@ -1036,6 +1041,28 @@ function validateInput(input, profile, flavor) {
1036
1041
  }
1037
1042
  }
1038
1043
  }
1044
+ if (req("BASIC_WL" /* BASIC_WL */) && input.references) {
1045
+ const singleOccurrence = ["order", "contract", "seller-order", "project", "despatch"];
1046
+ for (const type of singleOccurrence) {
1047
+ const count = input.references.filter((ref) => (ref.type ?? "order") === type).length;
1048
+ if (count > 1) {
1049
+ addError(
1050
+ errors,
1051
+ "references",
1052
+ `Only one "${type}" reference is allowed, got ${count}.`,
1053
+ "BASIC_WL" /* BASIC_WL */
1054
+ );
1055
+ }
1056
+ }
1057
+ if (input.delivery?.despatchAdviceReference && input.references.some((ref) => ref.type === "despatch")) {
1058
+ addError(
1059
+ errors,
1060
+ "references",
1061
+ 'Use either delivery.despatchAdviceReference or a "despatch" reference, not both.',
1062
+ "BASIC_WL" /* BASIC_WL */
1063
+ );
1064
+ }
1065
+ }
1039
1066
  if (flavor === "xrechnung" /* XRECHNUNG */) {
1040
1067
  if (!input.document?.buyerReference) {
1041
1068
  addError(
package/dist/index.d.cts CHANGED
@@ -596,7 +596,8 @@ interface InvoiceLineInput {
596
596
  */
597
597
  basisQuantityUnitCode?: UnitCode | (string & Record<never, never>);
598
598
  /**
599
- * Line total (net) — can be derived from `quantity * unitPrice`.
599
+ * Line total (net). Derived as `quantity * unitPrice / (basisQuantity ?? 1)`,
600
+ * rounded to two decimals, when omitted.
600
601
  * @see EN 16931 BT-131
601
602
  */
602
603
  lineTotal?: number;
@@ -947,6 +948,12 @@ interface DocumentReferenceInput {
947
948
  type?: "order" | "contract" | "despatch" | "preceding" | "seller-order" | "project";
948
949
  /** Issue date of the referenced document */
949
950
  issueDate?: string;
951
+ /**
952
+ * Project name, only used for `"project"` references. The CII schema
953
+ * requires a `ram:Name` next to the project ID (BT-11); falls back to
954
+ * `"Project"` when omitted.
955
+ */
956
+ name?: string;
950
957
  }
951
958
  /**
952
959
  * Billing/invoicing period.
package/dist/index.d.ts CHANGED
@@ -596,7 +596,8 @@ interface InvoiceLineInput {
596
596
  */
597
597
  basisQuantityUnitCode?: UnitCode | (string & Record<never, never>);
598
598
  /**
599
- * Line total (net) — can be derived from `quantity * unitPrice`.
599
+ * Line total (net). Derived as `quantity * unitPrice / (basisQuantity ?? 1)`,
600
+ * rounded to two decimals, when omitted.
600
601
  * @see EN 16931 BT-131
601
602
  */
602
603
  lineTotal?: number;
@@ -947,6 +948,12 @@ interface DocumentReferenceInput {
947
948
  type?: "order" | "contract" | "despatch" | "preceding" | "seller-order" | "project";
948
949
  /** Issue date of the referenced document */
949
950
  issueDate?: string;
951
+ /**
952
+ * Project name, only used for `"project"` references. The CII schema
953
+ * requires a `ram:Name` next to the project ID (BT-11); falls back to
954
+ * `"Project"` when omitted.
955
+ */
956
+ name?: string;
950
957
  }
951
958
  /**
952
959
  * Billing/invoicing period.
package/dist/index.js CHANGED
@@ -259,6 +259,9 @@ function toCents(n) {
259
259
  function roundHalfUp(n) {
260
260
  return toCents(n) / 100;
261
261
  }
262
+ function deriveLineNet(line) {
263
+ return roundHalfUp(line.quantity * line.unitPrice / (line.basisQuantity ?? 1));
264
+ }
262
265
 
263
266
  // src/core/xml-builder.ts
264
267
  var PROFILE_LEVEL = {
@@ -421,7 +424,7 @@ function buildLineItem(line, profile) {
421
424
  tx += tag("ram:RateApplicablePercent", line.vatRatePercent.toString());
422
425
  settle += tag("ram:ApplicableTradeTax", tx);
423
426
  }
424
- const lineTotal = line.lineTotal ?? roundHalfUp(line.quantity * line.unitPrice);
427
+ const lineTotal = line.lineTotal ?? deriveLineNet(line);
425
428
  settle += tag(
426
429
  "ram:SpecifiedTradeSettlementLineMonetarySummation",
427
430
  tag("ram:LineTotalAmount", fmtAmt(lineTotal))
@@ -540,27 +543,21 @@ function buildXml(input, profile, flavor) {
540
543
  buildParty(input.sellerTaxRepresentative, profile)
541
544
  );
542
545
  if (input.references && atLeast(profile, "BASIC_WL" /* BASIC_WL */)) {
543
- for (const ref of input.references) {
544
- const refType = ref.type ?? "order";
545
- if (refType === "preceding" || refType === "despatch") continue;
546
- const inner = tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
547
- switch (refType) {
548
- case "order":
549
- agree += tag("ram:BuyerOrderReferencedDocument", inner);
550
- break;
551
- case "contract":
552
- agree += tag("ram:ContractReferencedDocument", inner);
553
- break;
554
- case "seller-order":
555
- if (atLeast(profile, "EN16931" /* EN16931 */))
556
- agree += tag("ram:SellerOrderReferencedDocument", inner);
557
- break;
558
- case "project":
559
- if (atLeast(profile, "EN16931" /* EN16931 */))
560
- agree += tag("ram:SpecifiedProcuringProject", tag("ram:ID", escapeXml(ref.id)));
561
- break;
562
- }
563
- }
546
+ const refsOfType = (type) => input.references.filter((ref) => (ref.type ?? "order") === type);
547
+ const refDoc = (ref) => tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
548
+ if (atLeast(profile, "EN16931" /* EN16931 */))
549
+ for (const ref of refsOfType("seller-order"))
550
+ agree += tag("ram:SellerOrderReferencedDocument", refDoc(ref));
551
+ for (const ref of refsOfType("order"))
552
+ agree += tag("ram:BuyerOrderReferencedDocument", refDoc(ref));
553
+ for (const ref of refsOfType("contract"))
554
+ agree += tag("ram:ContractReferencedDocument", refDoc(ref));
555
+ if (atLeast(profile, "EN16931" /* EN16931 */))
556
+ for (const ref of refsOfType("project"))
557
+ agree += tag(
558
+ "ram:SpecifiedProcuringProject",
559
+ tag("ram:ID", escapeXml(ref.id)) + tag("ram:Name", escapeXml(ref.name ?? "Project"))
560
+ );
564
561
  }
565
562
  tx += tag("ram:ApplicableHeaderTradeAgreement", agree);
566
563
  let del = "";
@@ -855,6 +852,14 @@ function validateInput(input, profile, flavor) {
855
852
  if (line.unitPrice == null) {
856
853
  addError(errors, `lines[${i}].unitPrice`, "Line unit price is required", "BASIC" /* BASIC */);
857
854
  }
855
+ if (line.basisQuantity != null && !(line.basisQuantity > 0)) {
856
+ addError(
857
+ errors,
858
+ `lines[${i}].basisQuantity`,
859
+ "Line price base quantity (BT-149) must be greater than zero",
860
+ "BASIC" /* BASIC */
861
+ );
862
+ }
858
863
  });
859
864
  }
860
865
  }
@@ -951,8 +956,8 @@ function validateInput(input, profile, flavor) {
951
956
  }
952
957
  if (req("BASIC" /* BASIC */) && input.totals && input.lines) {
953
958
  const sumLineNets = input.lines.reduce((sum, l) => {
954
- const lineNet = l.lineTotal ?? (typeof l.quantity === "number" && typeof l.unitPrice === "number" ? l.quantity * l.unitPrice : 0);
955
- return sum + lineNet;
959
+ const lineNet = l.lineTotal ?? deriveLineNet(l);
960
+ return sum + (Number.isFinite(lineNet) ? lineNet : 0);
956
961
  }, 0);
957
962
  if (input.totals.lineTotal != null) {
958
963
  if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
@@ -1006,6 +1011,28 @@ function validateInput(input, profile, flavor) {
1006
1011
  }
1007
1012
  }
1008
1013
  }
1014
+ if (req("BASIC_WL" /* BASIC_WL */) && input.references) {
1015
+ const singleOccurrence = ["order", "contract", "seller-order", "project", "despatch"];
1016
+ for (const type of singleOccurrence) {
1017
+ const count = input.references.filter((ref) => (ref.type ?? "order") === type).length;
1018
+ if (count > 1) {
1019
+ addError(
1020
+ errors,
1021
+ "references",
1022
+ `Only one "${type}" reference is allowed, got ${count}.`,
1023
+ "BASIC_WL" /* BASIC_WL */
1024
+ );
1025
+ }
1026
+ }
1027
+ if (input.delivery?.despatchAdviceReference && input.references.some((ref) => ref.type === "despatch")) {
1028
+ addError(
1029
+ errors,
1030
+ "references",
1031
+ 'Use either delivery.despatchAdviceReference or a "despatch" reference, not both.',
1032
+ "BASIC_WL" /* BASIC_WL */
1033
+ );
1034
+ }
1035
+ }
1009
1036
  if (flavor === "xrechnung" /* XRECHNUNG */) {
1010
1037
  if (!input.document?.buyerReference) {
1011
1038
  addError(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackforge-eu/factur-x",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "type": "module",
5
5
  "description": "Embed Factur-X, ZUGFeRD, and XRechnung structured invoice XML into PDF/A-3 documents",
6
6
  "keywords": [