@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 +10 -0
- package/dist/index.cjs +25 -16
- package/dist/index.js +25 -16
- package/package.json +1 -1
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 ??
|
|
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 =
|
|
931
|
-
if (
|
|
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 =
|
|
947
|
-
if (
|
|
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 =
|
|
963
|
-
if (
|
|
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 (
|
|
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 (${
|
|
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 (
|
|
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 (${
|
|
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 =
|
|
1004
|
-
if (
|
|
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
|
|
1088
|
-
return Math.
|
|
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 ??
|
|
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 =
|
|
901
|
-
if (
|
|
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 =
|
|
917
|
-
if (
|
|
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 =
|
|
933
|
-
if (
|
|
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 (
|
|
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 (${
|
|
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 (
|
|
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 (${
|
|
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 =
|
|
974
|
-
if (
|
|
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
|
|
1058
|
-
return Math.
|
|
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();
|