@stackforge-eu/factur-x 1.4.2 → 1.4.4

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,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.4] — 2026-11-09
11
+
12
+ - **Fixed header reference element order.** `buildXml` emitted
13
+ `input.references` in input order, so an `order` reference listed before a
14
+ `seller-order` (or `contract`) reference produced XML that failed XSD
15
+ validation. References are now emitted in the CII sequence (SellerOrder →
16
+ BuyerOrder → Contract → SpecifiedProcuringProject); input order is only
17
+ kept among references of the same type. Reported by @SCPAU100 (#23).
18
+ - **Fixed `project` references failing XSD validation.** `SpecifiedProcuringProject`
19
+ requires a `ram:Name` child that was never emitted. A new optional `name`
20
+ field on `DocumentReferenceInput` fills it, defaulting to `"Project"`.
21
+ - **`validateInput` rejects duplicate single-occurrence references.** The
22
+ schema allows `order`, `contract`, `seller-order`, `project` and `despatch`
23
+ references at most once each, and `delivery.despatchAdviceReference` and a
24
+ `despatch` reference target the same element. Both cases used to surface
25
+ only as an XSD error after building.
26
+
27
+ ## [1.4.3] — 2026-09-28
28
+
29
+ - **Fixed half-cent rounding.** `Math.round(n * 100) / 100` turns
30
+ `402.50 × 19%` (`76.475`) into `76.47`, because `76.475 * 100` is
31
+ `7647.499999999999`, so `validateInput` rejected the correctly rounded
32
+ `76.48` (BR-CO-17). Amounts are now rounded half away from zero on a
33
+ noise-free value and compared in integer cents, in the validator and in
34
+ the XML builder alike. Reported by @johannesspohr (#21).
35
+
10
36
  ## [1.4.2] — 2026-09-25
11
37
 
12
38
  - **Fixed the embedded XML MIME type back to `text/xml`.** 1.2.1 switched the
@@ -265,6 +291,8 @@ This element is not expected`. Reordered the three blocks in
265
291
  - Deno compatibility test suite
266
292
 
267
293
  [Unreleased]: https://github.com/StackForge-EU/factur-x/commits/main
294
+ [1.4.4]: https://github.com/StackForge-EU/factur-x/compare/v1.4.3...v1.4.4
295
+ [1.4.3]: https://github.com/StackForge-EU/factur-x/compare/v1.4.2...v1.4.3
268
296
  [1.4.2]: https://github.com/StackForge-EU/factur-x/compare/v1.4.1...v1.4.2
269
297
  [1.4.1]: https://github.com/StackForge-EU/factur-x/compare/v1.4.0...v1.4.1
270
298
  [1.4.0]: https://github.com/StackForge-EU/factur-x/compare/v1.3.1...v1.4.0
package/dist/index.cjs CHANGED
@@ -281,6 +281,15 @@ function resolveBusinessProcessUrn(input, flavor) {
281
281
  return FLAVOR_CONFIGS[flavor].businessProcessUrn;
282
282
  }
283
283
 
284
+ // src/core/rounding.ts
285
+ function toCents(n) {
286
+ const cents = Math.round(Number((Math.abs(n) * 100).toPrecision(15)));
287
+ return n < 0 ? -cents : cents;
288
+ }
289
+ function roundHalfUp(n) {
290
+ return toCents(n) / 100;
291
+ }
292
+
284
293
  // src/core/xml-builder.ts
285
294
  var PROFILE_LEVEL = {
286
295
  MINIMUM: 0,
@@ -305,7 +314,7 @@ function fmtAmt(n) {
305
314
  if (!Number.isFinite(n)) {
306
315
  throw new Error(`Invalid amount value: ${n}. Expected a finite number.`);
307
316
  }
308
- return n.toFixed(2);
317
+ return roundHalfUp(n).toFixed(2);
309
318
  }
310
319
  function fmtPrice(n) {
311
320
  if (!Number.isFinite(n)) {
@@ -442,7 +451,7 @@ function buildLineItem(line, profile) {
442
451
  tx += tag("ram:RateApplicablePercent", line.vatRatePercent.toString());
443
452
  settle += tag("ram:ApplicableTradeTax", tx);
444
453
  }
445
- const lineTotal = line.lineTotal ?? Math.round(line.quantity * line.unitPrice * 100) / 100;
454
+ const lineTotal = line.lineTotal ?? roundHalfUp(line.quantity * line.unitPrice);
446
455
  settle += tag(
447
456
  "ram:SpecifiedTradeSettlementLineMonetarySummation",
448
457
  tag("ram:LineTotalAmount", fmtAmt(lineTotal))
@@ -561,27 +570,21 @@ function buildXml(input, profile, flavor) {
561
570
  buildParty(input.sellerTaxRepresentative, profile)
562
571
  );
563
572
  if (input.references && atLeast(profile, "BASIC_WL" /* BASIC_WL */)) {
564
- for (const ref of input.references) {
565
- const refType = ref.type ?? "order";
566
- if (refType === "preceding" || refType === "despatch") continue;
567
- const inner = tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
568
- switch (refType) {
569
- case "order":
570
- agree += tag("ram:BuyerOrderReferencedDocument", inner);
571
- break;
572
- case "contract":
573
- agree += tag("ram:ContractReferencedDocument", inner);
574
- break;
575
- case "seller-order":
576
- if (atLeast(profile, "EN16931" /* EN16931 */))
577
- agree += tag("ram:SellerOrderReferencedDocument", inner);
578
- break;
579
- case "project":
580
- if (atLeast(profile, "EN16931" /* EN16931 */))
581
- agree += tag("ram:SpecifiedProcuringProject", tag("ram:ID", escapeXml(ref.id)));
582
- break;
583
- }
584
- }
573
+ const refsOfType = (type) => input.references.filter((ref) => (ref.type ?? "order") === type);
574
+ const refDoc = (ref) => tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
575
+ if (atLeast(profile, "EN16931" /* EN16931 */))
576
+ for (const ref of refsOfType("seller-order"))
577
+ agree += tag("ram:SellerOrderReferencedDocument", refDoc(ref));
578
+ for (const ref of refsOfType("order"))
579
+ agree += tag("ram:BuyerOrderReferencedDocument", refDoc(ref));
580
+ for (const ref of refsOfType("contract"))
581
+ agree += tag("ram:ContractReferencedDocument", refDoc(ref));
582
+ if (atLeast(profile, "EN16931" /* EN16931 */))
583
+ for (const ref of refsOfType("project"))
584
+ agree += tag(
585
+ "ram:SpecifiedProcuringProject",
586
+ tag("ram:ID", escapeXml(ref.id)) + tag("ram:Name", escapeXml(ref.name ?? "Project"))
587
+ );
585
588
  }
586
589
  tx += tag("ram:ApplicableHeaderTradeAgreement", agree);
587
590
  let del = "";
@@ -927,8 +930,8 @@ function validateInput(input, profile, flavor) {
927
930
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals) {
928
931
  const t = input.totals;
929
932
  if (t.taxBasisTotal != null && t.taxTotal != null && t.grandTotal != null) {
930
- const expected = round2(t.taxBasisTotal + t.taxTotal);
931
- if (Math.abs(round2(t.grandTotal) - expected) > 0.01) {
933
+ const expected = roundHalfUp(t.taxBasisTotal + t.taxTotal);
934
+ if (amountsDiffer(t.grandTotal, expected)) {
932
935
  addError(
933
936
  errors,
934
937
  "totals.grandTotal",
@@ -943,8 +946,8 @@ function validateInput(input, profile, flavor) {
943
946
  if (t.grandTotal != null && t.duePayableAmount != null) {
944
947
  const prepaid = t.prepaidAmount ?? 0;
945
948
  const rounding = t.roundingAmount ?? 0;
946
- const expected = round2(t.grandTotal - prepaid + rounding);
947
- if (Math.abs(round2(t.duePayableAmount) - expected) > 0.01) {
949
+ const expected = roundHalfUp(t.grandTotal - prepaid + rounding);
950
+ if (amountsDiffer(t.duePayableAmount, expected)) {
948
951
  addError(
949
952
  errors,
950
953
  "totals.duePayableAmount",
@@ -959,8 +962,8 @@ function validateInput(input, profile, flavor) {
959
962
  if (t.lineTotal != null && t.taxBasisTotal != null) {
960
963
  const allowance = t.allowanceTotal ?? 0;
961
964
  const charge = t.chargeTotal ?? 0;
962
- const expected = round2(t.lineTotal - allowance + charge);
963
- if (Math.abs(round2(t.taxBasisTotal) - expected) > 0.01) {
965
+ const expected = roundHalfUp(t.lineTotal - allowance + charge);
966
+ if (amountsDiffer(t.taxBasisTotal, expected)) {
964
967
  addError(
965
968
  errors,
966
969
  "totals.taxBasisTotal",
@@ -976,11 +979,11 @@ function validateInput(input, profile, flavor) {
976
979
  return sum + lineNet;
977
980
  }, 0);
978
981
  if (input.totals.lineTotal != null) {
979
- if (Math.abs(round2(input.totals.lineTotal) - round2(sumLineNets)) > 0.01) {
982
+ if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
980
983
  addError(
981
984
  errors,
982
985
  "totals.lineTotal",
983
- `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${round2(sumLineNets)}) (BR-CO-10).`,
986
+ `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${roundHalfUp(sumLineNets)}) (BR-CO-10).`,
984
987
  "BASIC" /* BASIC */
985
988
  );
986
989
  }
@@ -988,11 +991,11 @@ function validateInput(input, profile, flavor) {
988
991
  }
989
992
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals?.taxTotal != null && input.vatBreakdown) {
990
993
  const sumVat = input.vatBreakdown.reduce((s, v) => s + (v.taxAmount ?? 0), 0);
991
- if (Math.abs(round2(input.totals.taxTotal) - round2(sumVat)) > 0.01) {
994
+ if (amountsDiffer(input.totals.taxTotal, sumVat)) {
992
995
  addError(
993
996
  errors,
994
997
  "totals.taxTotal",
995
- `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${round2(sumVat)}) (BR-CO-14).`,
998
+ `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${roundHalfUp(sumVat)}) (BR-CO-14).`,
996
999
  "BASIC_WL" /* BASIC_WL */
997
1000
  );
998
1001
  }
@@ -1000,8 +1003,8 @@ function validateInput(input, profile, flavor) {
1000
1003
  if (req("BASIC_WL" /* BASIC_WL */) && input.vatBreakdown) {
1001
1004
  input.vatBreakdown.forEach((vb, i) => {
1002
1005
  if (vb.taxableAmount != null && vb.taxAmount != null && vb.ratePercent != null) {
1003
- const expected = round2(vb.taxableAmount * vb.ratePercent / 100);
1004
- if (Math.abs(round2(vb.taxAmount) - expected) > 0.01) {
1006
+ const expected = roundHalfUp(vb.taxableAmount * vb.ratePercent / 100);
1007
+ if (amountsDiffer(vb.taxAmount, expected)) {
1005
1008
  addError(
1006
1009
  errors,
1007
1010
  `vatBreakdown[${i}].taxAmount`,
@@ -1027,6 +1030,28 @@ function validateInput(input, profile, flavor) {
1027
1030
  }
1028
1031
  }
1029
1032
  }
1033
+ if (req("BASIC_WL" /* BASIC_WL */) && input.references) {
1034
+ const singleOccurrence = ["order", "contract", "seller-order", "project", "despatch"];
1035
+ for (const type of singleOccurrence) {
1036
+ const count = input.references.filter((ref) => (ref.type ?? "order") === type).length;
1037
+ if (count > 1) {
1038
+ addError(
1039
+ errors,
1040
+ "references",
1041
+ `Only one "${type}" reference is allowed, got ${count}.`,
1042
+ "BASIC_WL" /* BASIC_WL */
1043
+ );
1044
+ }
1045
+ }
1046
+ if (input.delivery?.despatchAdviceReference && input.references.some((ref) => ref.type === "despatch")) {
1047
+ addError(
1048
+ errors,
1049
+ "references",
1050
+ 'Use either delivery.despatchAdviceReference or a "despatch" reference, not both.',
1051
+ "BASIC_WL" /* BASIC_WL */
1052
+ );
1053
+ }
1054
+ }
1030
1055
  if (flavor === "xrechnung" /* XRECHNUNG */) {
1031
1056
  if (!input.document?.buyerReference) {
1032
1057
  addError(
@@ -1084,8 +1109,8 @@ function validateInput(input, profile, flavor) {
1084
1109
  errors
1085
1110
  };
1086
1111
  }
1087
- function round2(n) {
1088
- return Math.round(n * 100) / 100;
1112
+ function amountsDiffer(actual, expected) {
1113
+ return Math.abs(toCents(actual) - toCents(expected)) > 1;
1089
1114
  }
1090
1115
  function isValidIban(iban) {
1091
1116
  const normalized = iban.replace(/\s+/g, "").toUpperCase();
package/dist/index.d.cts CHANGED
@@ -947,6 +947,12 @@ interface DocumentReferenceInput {
947
947
  type?: "order" | "contract" | "despatch" | "preceding" | "seller-order" | "project";
948
948
  /** Issue date of the referenced document */
949
949
  issueDate?: string;
950
+ /**
951
+ * Project name, only used for `"project"` references. The CII schema
952
+ * requires a `ram:Name` next to the project ID (BT-11); falls back to
953
+ * `"Project"` when omitted.
954
+ */
955
+ name?: string;
950
956
  }
951
957
  /**
952
958
  * Billing/invoicing period.
package/dist/index.d.ts CHANGED
@@ -947,6 +947,12 @@ interface DocumentReferenceInput {
947
947
  type?: "order" | "contract" | "despatch" | "preceding" | "seller-order" | "project";
948
948
  /** Issue date of the referenced document */
949
949
  issueDate?: string;
950
+ /**
951
+ * Project name, only used for `"project"` references. The CII schema
952
+ * requires a `ram:Name` next to the project ID (BT-11); falls back to
953
+ * `"Project"` when omitted.
954
+ */
955
+ name?: string;
950
956
  }
951
957
  /**
952
958
  * Billing/invoicing period.
package/dist/index.js CHANGED
@@ -251,6 +251,15 @@ function resolveBusinessProcessUrn(input, flavor) {
251
251
  return FLAVOR_CONFIGS[flavor].businessProcessUrn;
252
252
  }
253
253
 
254
+ // src/core/rounding.ts
255
+ function toCents(n) {
256
+ const cents = Math.round(Number((Math.abs(n) * 100).toPrecision(15)));
257
+ return n < 0 ? -cents : cents;
258
+ }
259
+ function roundHalfUp(n) {
260
+ return toCents(n) / 100;
261
+ }
262
+
254
263
  // src/core/xml-builder.ts
255
264
  var PROFILE_LEVEL = {
256
265
  MINIMUM: 0,
@@ -275,7 +284,7 @@ function fmtAmt(n) {
275
284
  if (!Number.isFinite(n)) {
276
285
  throw new Error(`Invalid amount value: ${n}. Expected a finite number.`);
277
286
  }
278
- return n.toFixed(2);
287
+ return roundHalfUp(n).toFixed(2);
279
288
  }
280
289
  function fmtPrice(n) {
281
290
  if (!Number.isFinite(n)) {
@@ -412,7 +421,7 @@ function buildLineItem(line, profile) {
412
421
  tx += tag("ram:RateApplicablePercent", line.vatRatePercent.toString());
413
422
  settle += tag("ram:ApplicableTradeTax", tx);
414
423
  }
415
- const lineTotal = line.lineTotal ?? Math.round(line.quantity * line.unitPrice * 100) / 100;
424
+ const lineTotal = line.lineTotal ?? roundHalfUp(line.quantity * line.unitPrice);
416
425
  settle += tag(
417
426
  "ram:SpecifiedTradeSettlementLineMonetarySummation",
418
427
  tag("ram:LineTotalAmount", fmtAmt(lineTotal))
@@ -531,27 +540,21 @@ function buildXml(input, profile, flavor) {
531
540
  buildParty(input.sellerTaxRepresentative, profile)
532
541
  );
533
542
  if (input.references && atLeast(profile, "BASIC_WL" /* BASIC_WL */)) {
534
- for (const ref of input.references) {
535
- const refType = ref.type ?? "order";
536
- if (refType === "preceding" || refType === "despatch") continue;
537
- const inner = tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
538
- switch (refType) {
539
- case "order":
540
- agree += tag("ram:BuyerOrderReferencedDocument", inner);
541
- break;
542
- case "contract":
543
- agree += tag("ram:ContractReferencedDocument", inner);
544
- break;
545
- case "seller-order":
546
- if (atLeast(profile, "EN16931" /* EN16931 */))
547
- agree += tag("ram:SellerOrderReferencedDocument", inner);
548
- break;
549
- case "project":
550
- if (atLeast(profile, "EN16931" /* EN16931 */))
551
- agree += tag("ram:SpecifiedProcuringProject", tag("ram:ID", escapeXml(ref.id)));
552
- break;
553
- }
554
- }
543
+ const refsOfType = (type) => input.references.filter((ref) => (ref.type ?? "order") === type);
544
+ const refDoc = (ref) => tag("ram:IssuerAssignedID", escapeXml(ref.id)) + (ref.issueDate ? dateEl("ram:FormattedIssueDateTime", ref.issueDate) : "");
545
+ if (atLeast(profile, "EN16931" /* EN16931 */))
546
+ for (const ref of refsOfType("seller-order"))
547
+ agree += tag("ram:SellerOrderReferencedDocument", refDoc(ref));
548
+ for (const ref of refsOfType("order"))
549
+ agree += tag("ram:BuyerOrderReferencedDocument", refDoc(ref));
550
+ for (const ref of refsOfType("contract"))
551
+ agree += tag("ram:ContractReferencedDocument", refDoc(ref));
552
+ if (atLeast(profile, "EN16931" /* EN16931 */))
553
+ for (const ref of refsOfType("project"))
554
+ agree += tag(
555
+ "ram:SpecifiedProcuringProject",
556
+ tag("ram:ID", escapeXml(ref.id)) + tag("ram:Name", escapeXml(ref.name ?? "Project"))
557
+ );
555
558
  }
556
559
  tx += tag("ram:ApplicableHeaderTradeAgreement", agree);
557
560
  let del = "";
@@ -897,8 +900,8 @@ function validateInput(input, profile, flavor) {
897
900
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals) {
898
901
  const t = input.totals;
899
902
  if (t.taxBasisTotal != null && t.taxTotal != null && t.grandTotal != null) {
900
- const expected = round2(t.taxBasisTotal + t.taxTotal);
901
- if (Math.abs(round2(t.grandTotal) - expected) > 0.01) {
903
+ const expected = roundHalfUp(t.taxBasisTotal + t.taxTotal);
904
+ if (amountsDiffer(t.grandTotal, expected)) {
902
905
  addError(
903
906
  errors,
904
907
  "totals.grandTotal",
@@ -913,8 +916,8 @@ function validateInput(input, profile, flavor) {
913
916
  if (t.grandTotal != null && t.duePayableAmount != null) {
914
917
  const prepaid = t.prepaidAmount ?? 0;
915
918
  const rounding = t.roundingAmount ?? 0;
916
- const expected = round2(t.grandTotal - prepaid + rounding);
917
- if (Math.abs(round2(t.duePayableAmount) - expected) > 0.01) {
919
+ const expected = roundHalfUp(t.grandTotal - prepaid + rounding);
920
+ if (amountsDiffer(t.duePayableAmount, expected)) {
918
921
  addError(
919
922
  errors,
920
923
  "totals.duePayableAmount",
@@ -929,8 +932,8 @@ function validateInput(input, profile, flavor) {
929
932
  if (t.lineTotal != null && t.taxBasisTotal != null) {
930
933
  const allowance = t.allowanceTotal ?? 0;
931
934
  const charge = t.chargeTotal ?? 0;
932
- const expected = round2(t.lineTotal - allowance + charge);
933
- if (Math.abs(round2(t.taxBasisTotal) - expected) > 0.01) {
935
+ const expected = roundHalfUp(t.lineTotal - allowance + charge);
936
+ if (amountsDiffer(t.taxBasisTotal, expected)) {
934
937
  addError(
935
938
  errors,
936
939
  "totals.taxBasisTotal",
@@ -946,11 +949,11 @@ function validateInput(input, profile, flavor) {
946
949
  return sum + lineNet;
947
950
  }, 0);
948
951
  if (input.totals.lineTotal != null) {
949
- if (Math.abs(round2(input.totals.lineTotal) - round2(sumLineNets)) > 0.01) {
952
+ if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
950
953
  addError(
951
954
  errors,
952
955
  "totals.lineTotal",
953
- `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${round2(sumLineNets)}) (BR-CO-10).`,
956
+ `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${roundHalfUp(sumLineNets)}) (BR-CO-10).`,
954
957
  "BASIC" /* BASIC */
955
958
  );
956
959
  }
@@ -958,11 +961,11 @@ function validateInput(input, profile, flavor) {
958
961
  }
959
962
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals?.taxTotal != null && input.vatBreakdown) {
960
963
  const sumVat = input.vatBreakdown.reduce((s, v) => s + (v.taxAmount ?? 0), 0);
961
- if (Math.abs(round2(input.totals.taxTotal) - round2(sumVat)) > 0.01) {
964
+ if (amountsDiffer(input.totals.taxTotal, sumVat)) {
962
965
  addError(
963
966
  errors,
964
967
  "totals.taxTotal",
965
- `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${round2(sumVat)}) (BR-CO-14).`,
968
+ `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${roundHalfUp(sumVat)}) (BR-CO-14).`,
966
969
  "BASIC_WL" /* BASIC_WL */
967
970
  );
968
971
  }
@@ -970,8 +973,8 @@ function validateInput(input, profile, flavor) {
970
973
  if (req("BASIC_WL" /* BASIC_WL */) && input.vatBreakdown) {
971
974
  input.vatBreakdown.forEach((vb, i) => {
972
975
  if (vb.taxableAmount != null && vb.taxAmount != null && vb.ratePercent != null) {
973
- const expected = round2(vb.taxableAmount * vb.ratePercent / 100);
974
- if (Math.abs(round2(vb.taxAmount) - expected) > 0.01) {
976
+ const expected = roundHalfUp(vb.taxableAmount * vb.ratePercent / 100);
977
+ if (amountsDiffer(vb.taxAmount, expected)) {
975
978
  addError(
976
979
  errors,
977
980
  `vatBreakdown[${i}].taxAmount`,
@@ -997,6 +1000,28 @@ function validateInput(input, profile, flavor) {
997
1000
  }
998
1001
  }
999
1002
  }
1003
+ if (req("BASIC_WL" /* BASIC_WL */) && input.references) {
1004
+ const singleOccurrence = ["order", "contract", "seller-order", "project", "despatch"];
1005
+ for (const type of singleOccurrence) {
1006
+ const count = input.references.filter((ref) => (ref.type ?? "order") === type).length;
1007
+ if (count > 1) {
1008
+ addError(
1009
+ errors,
1010
+ "references",
1011
+ `Only one "${type}" reference is allowed, got ${count}.`,
1012
+ "BASIC_WL" /* BASIC_WL */
1013
+ );
1014
+ }
1015
+ }
1016
+ if (input.delivery?.despatchAdviceReference && input.references.some((ref) => ref.type === "despatch")) {
1017
+ addError(
1018
+ errors,
1019
+ "references",
1020
+ 'Use either delivery.despatchAdviceReference or a "despatch" reference, not both.',
1021
+ "BASIC_WL" /* BASIC_WL */
1022
+ );
1023
+ }
1024
+ }
1000
1025
  if (flavor === "xrechnung" /* XRECHNUNG */) {
1001
1026
  if (!input.document?.buyerReference) {
1002
1027
  addError(
@@ -1054,8 +1079,8 @@ function validateInput(input, profile, flavor) {
1054
1079
  errors
1055
1080
  };
1056
1081
  }
1057
- function round2(n) {
1058
- return Math.round(n * 100) / 100;
1082
+ function amountsDiffer(actual, expected) {
1083
+ return Math.abs(toCents(actual) - toCents(expected)) > 1;
1059
1084
  }
1060
1085
  function isValidIban(iban) {
1061
1086
  const normalized = iban.replace(/\s+/g, "").toUpperCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackforge-eu/factur-x",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "type": "module",
5
5
  "description": "Embed Factur-X, ZUGFeRD, and XRechnung structured invoice XML into PDF/A-3 documents",
6
6
  "keywords": [