@stackforge-eu/factur-x 1.4.2 → 1.4.3

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,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.3] — 2026-09-28
11
+
12
+ - **Fixed half-cent rounding.** `Math.round(n * 100) / 100` turns
13
+ `402.50 × 19%` (`76.475`) into `76.47`, because `76.475 * 100` is
14
+ `7647.499999999999`, so `validateInput` rejected the correctly rounded
15
+ `76.48` (BR-CO-17). Amounts are now rounded half away from zero on a
16
+ noise-free value and compared in integer cents, in the validator and in
17
+ the XML builder alike. Reported by @johannesspohr (#21).
18
+
10
19
  ## [1.4.2] — 2026-09-25
11
20
 
12
21
  - **Fixed the embedded XML MIME type back to `text/xml`.** 1.2.1 switched the
@@ -265,6 +274,7 @@ This element is not expected`. Reordered the three blocks in
265
274
  - Deno compatibility test suite
266
275
 
267
276
  [Unreleased]: https://github.com/StackForge-EU/factur-x/commits/main
277
+ [1.4.3]: https://github.com/StackForge-EU/factur-x/compare/v1.4.2...v1.4.3
268
278
  [1.4.2]: https://github.com/StackForge-EU/factur-x/compare/v1.4.1...v1.4.2
269
279
  [1.4.1]: https://github.com/StackForge-EU/factur-x/compare/v1.4.0...v1.4.1
270
280
  [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))
@@ -927,8 +936,8 @@ function validateInput(input, profile, flavor) {
927
936
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals) {
928
937
  const t = input.totals;
929
938
  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) {
939
+ const expected = roundHalfUp(t.taxBasisTotal + t.taxTotal);
940
+ if (amountsDiffer(t.grandTotal, expected)) {
932
941
  addError(
933
942
  errors,
934
943
  "totals.grandTotal",
@@ -943,8 +952,8 @@ function validateInput(input, profile, flavor) {
943
952
  if (t.grandTotal != null && t.duePayableAmount != null) {
944
953
  const prepaid = t.prepaidAmount ?? 0;
945
954
  const rounding = t.roundingAmount ?? 0;
946
- const expected = round2(t.grandTotal - prepaid + rounding);
947
- if (Math.abs(round2(t.duePayableAmount) - expected) > 0.01) {
955
+ const expected = roundHalfUp(t.grandTotal - prepaid + rounding);
956
+ if (amountsDiffer(t.duePayableAmount, expected)) {
948
957
  addError(
949
958
  errors,
950
959
  "totals.duePayableAmount",
@@ -959,8 +968,8 @@ function validateInput(input, profile, flavor) {
959
968
  if (t.lineTotal != null && t.taxBasisTotal != null) {
960
969
  const allowance = t.allowanceTotal ?? 0;
961
970
  const charge = t.chargeTotal ?? 0;
962
- const expected = round2(t.lineTotal - allowance + charge);
963
- if (Math.abs(round2(t.taxBasisTotal) - expected) > 0.01) {
971
+ const expected = roundHalfUp(t.lineTotal - allowance + charge);
972
+ if (amountsDiffer(t.taxBasisTotal, expected)) {
964
973
  addError(
965
974
  errors,
966
975
  "totals.taxBasisTotal",
@@ -976,11 +985,11 @@ function validateInput(input, profile, flavor) {
976
985
  return sum + lineNet;
977
986
  }, 0);
978
987
  if (input.totals.lineTotal != null) {
979
- if (Math.abs(round2(input.totals.lineTotal) - round2(sumLineNets)) > 0.01) {
988
+ if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
980
989
  addError(
981
990
  errors,
982
991
  "totals.lineTotal",
983
- `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${round2(sumLineNets)}) (BR-CO-10).`,
992
+ `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${roundHalfUp(sumLineNets)}) (BR-CO-10).`,
984
993
  "BASIC" /* BASIC */
985
994
  );
986
995
  }
@@ -988,11 +997,11 @@ function validateInput(input, profile, flavor) {
988
997
  }
989
998
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals?.taxTotal != null && input.vatBreakdown) {
990
999
  const sumVat = input.vatBreakdown.reduce((s, v) => s + (v.taxAmount ?? 0), 0);
991
- if (Math.abs(round2(input.totals.taxTotal) - round2(sumVat)) > 0.01) {
1000
+ if (amountsDiffer(input.totals.taxTotal, sumVat)) {
992
1001
  addError(
993
1002
  errors,
994
1003
  "totals.taxTotal",
995
- `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${round2(sumVat)}) (BR-CO-14).`,
1004
+ `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${roundHalfUp(sumVat)}) (BR-CO-14).`,
996
1005
  "BASIC_WL" /* BASIC_WL */
997
1006
  );
998
1007
  }
@@ -1000,8 +1009,8 @@ function validateInput(input, profile, flavor) {
1000
1009
  if (req("BASIC_WL" /* BASIC_WL */) && input.vatBreakdown) {
1001
1010
  input.vatBreakdown.forEach((vb, i) => {
1002
1011
  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) {
1012
+ const expected = roundHalfUp(vb.taxableAmount * vb.ratePercent / 100);
1013
+ if (amountsDiffer(vb.taxAmount, expected)) {
1005
1014
  addError(
1006
1015
  errors,
1007
1016
  `vatBreakdown[${i}].taxAmount`,
@@ -1084,8 +1093,8 @@ function validateInput(input, profile, flavor) {
1084
1093
  errors
1085
1094
  };
1086
1095
  }
1087
- function round2(n) {
1088
- return Math.round(n * 100) / 100;
1096
+ function amountsDiffer(actual, expected) {
1097
+ return Math.abs(toCents(actual) - toCents(expected)) > 1;
1089
1098
  }
1090
1099
  function isValidIban(iban) {
1091
1100
  const normalized = iban.replace(/\s+/g, "").toUpperCase();
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))
@@ -897,8 +906,8 @@ function validateInput(input, profile, flavor) {
897
906
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals) {
898
907
  const t = input.totals;
899
908
  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) {
909
+ const expected = roundHalfUp(t.taxBasisTotal + t.taxTotal);
910
+ if (amountsDiffer(t.grandTotal, expected)) {
902
911
  addError(
903
912
  errors,
904
913
  "totals.grandTotal",
@@ -913,8 +922,8 @@ function validateInput(input, profile, flavor) {
913
922
  if (t.grandTotal != null && t.duePayableAmount != null) {
914
923
  const prepaid = t.prepaidAmount ?? 0;
915
924
  const rounding = t.roundingAmount ?? 0;
916
- const expected = round2(t.grandTotal - prepaid + rounding);
917
- if (Math.abs(round2(t.duePayableAmount) - expected) > 0.01) {
925
+ const expected = roundHalfUp(t.grandTotal - prepaid + rounding);
926
+ if (amountsDiffer(t.duePayableAmount, expected)) {
918
927
  addError(
919
928
  errors,
920
929
  "totals.duePayableAmount",
@@ -929,8 +938,8 @@ function validateInput(input, profile, flavor) {
929
938
  if (t.lineTotal != null && t.taxBasisTotal != null) {
930
939
  const allowance = t.allowanceTotal ?? 0;
931
940
  const charge = t.chargeTotal ?? 0;
932
- const expected = round2(t.lineTotal - allowance + charge);
933
- if (Math.abs(round2(t.taxBasisTotal) - expected) > 0.01) {
941
+ const expected = roundHalfUp(t.lineTotal - allowance + charge);
942
+ if (amountsDiffer(t.taxBasisTotal, expected)) {
934
943
  addError(
935
944
  errors,
936
945
  "totals.taxBasisTotal",
@@ -946,11 +955,11 @@ function validateInput(input, profile, flavor) {
946
955
  return sum + lineNet;
947
956
  }, 0);
948
957
  if (input.totals.lineTotal != null) {
949
- if (Math.abs(round2(input.totals.lineTotal) - round2(sumLineNets)) > 0.01) {
958
+ if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
950
959
  addError(
951
960
  errors,
952
961
  "totals.lineTotal",
953
- `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${round2(sumLineNets)}) (BR-CO-10).`,
962
+ `lineTotal ${input.totals.lineTotal} does not equal \u03A3 line nets (${roundHalfUp(sumLineNets)}) (BR-CO-10).`,
954
963
  "BASIC" /* BASIC */
955
964
  );
956
965
  }
@@ -958,11 +967,11 @@ function validateInput(input, profile, flavor) {
958
967
  }
959
968
  if (req("BASIC_WL" /* BASIC_WL */) && input.totals?.taxTotal != null && input.vatBreakdown) {
960
969
  const sumVat = input.vatBreakdown.reduce((s, v) => s + (v.taxAmount ?? 0), 0);
961
- if (Math.abs(round2(input.totals.taxTotal) - round2(sumVat)) > 0.01) {
970
+ if (amountsDiffer(input.totals.taxTotal, sumVat)) {
962
971
  addError(
963
972
  errors,
964
973
  "totals.taxTotal",
965
- `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${round2(sumVat)}) (BR-CO-14).`,
974
+ `taxTotal ${input.totals.taxTotal} does not equal \u03A3 vatBreakdown.taxAmount (${roundHalfUp(sumVat)}) (BR-CO-14).`,
966
975
  "BASIC_WL" /* BASIC_WL */
967
976
  );
968
977
  }
@@ -970,8 +979,8 @@ function validateInput(input, profile, flavor) {
970
979
  if (req("BASIC_WL" /* BASIC_WL */) && input.vatBreakdown) {
971
980
  input.vatBreakdown.forEach((vb, i) => {
972
981
  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) {
982
+ const expected = roundHalfUp(vb.taxableAmount * vb.ratePercent / 100);
983
+ if (amountsDiffer(vb.taxAmount, expected)) {
975
984
  addError(
976
985
  errors,
977
986
  `vatBreakdown[${i}].taxAmount`,
@@ -1054,8 +1063,8 @@ function validateInput(input, profile, flavor) {
1054
1063
  errors
1055
1064
  };
1056
1065
  }
1057
- function round2(n) {
1058
- return Math.round(n * 100) / 100;
1066
+ function amountsDiffer(actual, expected) {
1067
+ return Math.abs(toCents(actual) - toCents(expected)) > 1;
1059
1068
  }
1060
1069
  function isValidIban(iban) {
1061
1070
  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.3",
4
4
  "type": "module",
5
5
  "description": "Embed Factur-X, ZUGFeRD, and XRechnung structured invoice XML into PDF/A-3 documents",
6
6
  "keywords": [