@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 +29 -0
- package/dist/index.cjs +51 -24
- package/dist/index.d.cts +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +51 -24
- package/package.json +1 -1
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 ??
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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 ?? (
|
|
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)
|
|
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)
|
|
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 ??
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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 ?? (
|
|
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(
|