@stackforge-eu/factur-x 1.4.4 → 1.4.6

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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.6] — 2026-11-14
11
+
12
+ - **Fixed MINIMUM XML failing XSD validation when optional party fields are
13
+ set.** `buildXml` wrote a party's `id`, `globalId`,
14
+ `legalOrganization.tradingName` and `electronicAddress` in every profile,
15
+ but the MINIMUM schema allows none of them. They are now dropped for
16
+ MINIMUM, like `address` and `contact` already were, and still written from
17
+ BASIC WL upwards (#28).
18
+
19
+ ## [1.4.5] — 2026-11-14
20
+
21
+ - **Fixed derived line net amount ignoring `basisQuantity`.** When a line
22
+ omits `lineTotal`, `buildXml` and the BR-CO-10 check in `validateInput`
23
+ computed it as `quantity * unitPrice`, so a price per 100 units produced a
24
+ line total 100× too high and a correct `totals.lineTotal` was rejected. The
25
+ amount is now `quantity * unitPrice / basisQuantity` (EN 16931 BT-131),
26
+ rounded per line in both places. Reported by @trackoor (#26).
27
+ - **`validateInput` rejects a `basisQuantity` that is not greater than zero.**
28
+
10
29
  ## [1.4.4] — 2026-11-09
11
30
 
12
31
  - **Fixed header reference element order.** `buildXml` emitted
@@ -291,6 +310,8 @@ This element is not expected`. Reordered the three blocks in
291
310
  - Deno compatibility test suite
292
311
 
293
312
  [Unreleased]: https://github.com/StackForge-EU/factur-x/commits/main
313
+ [1.4.6]: https://github.com/StackForge-EU/factur-x/compare/v1.4.5...v1.4.6
314
+ [1.4.5]: https://github.com/StackForge-EU/factur-x/compare/v1.4.4...v1.4.5
294
315
  [1.4.4]: https://github.com/StackForge-EU/factur-x/compare/v1.4.3...v1.4.4
295
316
  [1.4.3]: https://github.com/StackForge-EU/factur-x/compare/v1.4.2...v1.4.3
296
317
  [1.4.2]: https://github.com/StackForge-EU/factur-x/compare/v1.4.1...v1.4.2
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 = {
@@ -352,11 +355,12 @@ function buildContact(c) {
352
355
  }
353
356
  function buildParty(p, profile) {
354
357
  let x = "";
355
- if (p.id) {
358
+ const beyondMinimum = atLeast(profile, "BASIC_WL" /* BASIC_WL */);
359
+ if (p.id && beyondMinimum) {
356
360
  const ids = Array.isArray(p.id) ? p.id : [p.id];
357
361
  for (const id of ids) x += tag("ram:ID", escapeXml(id));
358
362
  }
359
- if (p.globalId) {
363
+ if (p.globalId && beyondMinimum) {
360
364
  x += tag("ram:GlobalID", escapeXml(p.globalId.value), { schemeID: p.globalId.schemeID });
361
365
  }
362
366
  x += tag("ram:Name", escapeXml(p.name));
@@ -371,13 +375,13 @@ function buildParty(p, profile) {
371
375
  p.legalOrganization.schemeID ? { schemeID: p.legalOrganization.schemeID } : void 0
372
376
  );
373
377
  }
374
- if (p.legalOrganization.tradingName)
378
+ if (p.legalOrganization.tradingName && beyondMinimum)
375
379
  lo += tag("ram:TradingBusinessName", escapeXml(p.legalOrganization.tradingName));
376
380
  x += tag("ram:SpecifiedLegalOrganization", lo);
377
381
  }
378
382
  if (p.contact && atLeast(profile, "EN16931" /* EN16931 */)) x += buildContact(p.contact);
379
- if (p.address && atLeast(profile, "BASIC_WL" /* BASIC_WL */)) x += buildAddress(p.address);
380
- if (p.electronicAddress)
383
+ if (p.address && beyondMinimum) x += buildAddress(p.address);
384
+ if (p.electronicAddress && beyondMinimum)
381
385
  x += tag(
382
386
  "ram:URIUniversalCommunication",
383
387
  tag("ram:URIID", escapeXml(p.electronicAddress.value), {
@@ -451,7 +455,7 @@ function buildLineItem(line, profile) {
451
455
  tx += tag("ram:RateApplicablePercent", line.vatRatePercent.toString());
452
456
  settle += tag("ram:ApplicableTradeTax", tx);
453
457
  }
454
- const lineTotal = line.lineTotal ?? roundHalfUp(line.quantity * line.unitPrice);
458
+ const lineTotal = line.lineTotal ?? deriveLineNet(line);
455
459
  settle += tag(
456
460
  "ram:SpecifiedTradeSettlementLineMonetarySummation",
457
461
  tag("ram:LineTotalAmount", fmtAmt(lineTotal))
@@ -879,6 +883,14 @@ function validateInput(input, profile, flavor) {
879
883
  if (line.unitPrice == null) {
880
884
  addError(errors, `lines[${i}].unitPrice`, "Line unit price is required", "BASIC" /* BASIC */);
881
885
  }
886
+ if (line.basisQuantity != null && !(line.basisQuantity > 0)) {
887
+ addError(
888
+ errors,
889
+ `lines[${i}].basisQuantity`,
890
+ "Line price base quantity (BT-149) must be greater than zero",
891
+ "BASIC" /* BASIC */
892
+ );
893
+ }
882
894
  });
883
895
  }
884
896
  }
@@ -975,8 +987,8 @@ function validateInput(input, profile, flavor) {
975
987
  }
976
988
  if (req("BASIC" /* BASIC */) && input.totals && input.lines) {
977
989
  const sumLineNets = input.lines.reduce((sum, l) => {
978
- const lineNet = l.lineTotal ?? (typeof l.quantity === "number" && typeof l.unitPrice === "number" ? l.quantity * l.unitPrice : 0);
979
- return sum + lineNet;
990
+ const lineNet = l.lineTotal ?? deriveLineNet(l);
991
+ return sum + (Number.isFinite(lineNet) ? lineNet : 0);
980
992
  }, 0);
981
993
  if (input.totals.lineTotal != null) {
982
994
  if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
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;
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;
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 = {
@@ -322,11 +325,12 @@ function buildContact(c) {
322
325
  }
323
326
  function buildParty(p, profile) {
324
327
  let x = "";
325
- if (p.id) {
328
+ const beyondMinimum = atLeast(profile, "BASIC_WL" /* BASIC_WL */);
329
+ if (p.id && beyondMinimum) {
326
330
  const ids = Array.isArray(p.id) ? p.id : [p.id];
327
331
  for (const id of ids) x += tag("ram:ID", escapeXml(id));
328
332
  }
329
- if (p.globalId) {
333
+ if (p.globalId && beyondMinimum) {
330
334
  x += tag("ram:GlobalID", escapeXml(p.globalId.value), { schemeID: p.globalId.schemeID });
331
335
  }
332
336
  x += tag("ram:Name", escapeXml(p.name));
@@ -341,13 +345,13 @@ function buildParty(p, profile) {
341
345
  p.legalOrganization.schemeID ? { schemeID: p.legalOrganization.schemeID } : void 0
342
346
  );
343
347
  }
344
- if (p.legalOrganization.tradingName)
348
+ if (p.legalOrganization.tradingName && beyondMinimum)
345
349
  lo += tag("ram:TradingBusinessName", escapeXml(p.legalOrganization.tradingName));
346
350
  x += tag("ram:SpecifiedLegalOrganization", lo);
347
351
  }
348
352
  if (p.contact && atLeast(profile, "EN16931" /* EN16931 */)) x += buildContact(p.contact);
349
- if (p.address && atLeast(profile, "BASIC_WL" /* BASIC_WL */)) x += buildAddress(p.address);
350
- if (p.electronicAddress)
353
+ if (p.address && beyondMinimum) x += buildAddress(p.address);
354
+ if (p.electronicAddress && beyondMinimum)
351
355
  x += tag(
352
356
  "ram:URIUniversalCommunication",
353
357
  tag("ram:URIID", escapeXml(p.electronicAddress.value), {
@@ -421,7 +425,7 @@ function buildLineItem(line, profile) {
421
425
  tx += tag("ram:RateApplicablePercent", line.vatRatePercent.toString());
422
426
  settle += tag("ram:ApplicableTradeTax", tx);
423
427
  }
424
- const lineTotal = line.lineTotal ?? roundHalfUp(line.quantity * line.unitPrice);
428
+ const lineTotal = line.lineTotal ?? deriveLineNet(line);
425
429
  settle += tag(
426
430
  "ram:SpecifiedTradeSettlementLineMonetarySummation",
427
431
  tag("ram:LineTotalAmount", fmtAmt(lineTotal))
@@ -849,6 +853,14 @@ function validateInput(input, profile, flavor) {
849
853
  if (line.unitPrice == null) {
850
854
  addError(errors, `lines[${i}].unitPrice`, "Line unit price is required", "BASIC" /* BASIC */);
851
855
  }
856
+ if (line.basisQuantity != null && !(line.basisQuantity > 0)) {
857
+ addError(
858
+ errors,
859
+ `lines[${i}].basisQuantity`,
860
+ "Line price base quantity (BT-149) must be greater than zero",
861
+ "BASIC" /* BASIC */
862
+ );
863
+ }
852
864
  });
853
865
  }
854
866
  }
@@ -945,8 +957,8 @@ function validateInput(input, profile, flavor) {
945
957
  }
946
958
  if (req("BASIC" /* BASIC */) && input.totals && input.lines) {
947
959
  const sumLineNets = input.lines.reduce((sum, l) => {
948
- const lineNet = l.lineTotal ?? (typeof l.quantity === "number" && typeof l.unitPrice === "number" ? l.quantity * l.unitPrice : 0);
949
- return sum + lineNet;
960
+ const lineNet = l.lineTotal ?? deriveLineNet(l);
961
+ return sum + (Number.isFinite(lineNet) ? lineNet : 0);
950
962
  }, 0);
951
963
  if (input.totals.lineTotal != null) {
952
964
  if (amountsDiffer(input.totals.lineTotal, sumLineNets)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackforge-eu/factur-x",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "type": "module",
5
5
  "description": "Embed Factur-X, ZUGFeRD, and XRechnung structured invoice XML into PDF/A-3 documents",
6
6
  "keywords": [
@@ -41,7 +41,7 @@
41
41
  "type": "git",
42
42
  "url": "git+https://github.com/StackForge-EU/factur-x.git"
43
43
  },
44
- "homepage": "https://www.stack-forge.eu",
44
+ "homepage": "https://factur-x.deployo.eu",
45
45
  "bugs": {
46
46
  "url": "https://github.com/StackForge-EU/factur-x/issues"
47
47
  },