Fix line-level SpecifiedTradeAllowanceCharge BasisAmount (BT-137/BT-142)

The line-level BasisAmount in SpecifiedTradeAllowanceCharge must equal
the value the percentage is applied to (BT-137/BT-142 semantic
definition, EN 16931-1:2017+A1:2019). For an item with basisQuantity != 1
that value is (price / basisQuantity) * quantity = the line subtotal, not
the per-unit price/basisQuantity value.

Also matches the line-net formula confirmed for EN 16931-1:2026 (BR-67,
ConnectingEurope/eInvoicing-EN16931 issue #445).

- ZUGFeRD2PullProvider.getItemTotalAllowanceChargeStr: emit
  currencyFormat(item.getValue().multiply(item.getQuantity())).
- getAllowanceChargeStr (product-level, BG-29 GrossPrice path) is
  intentionally NOT changed: there BasisAmount is the gross unit price
  per BT-148, which is per-unit by definition.
- CalculationTest: two new XML-level regression tests asserting
  BasisAmount and ActualAmount in emitted CII XML for allowance and
  charge cases with basisQuantity != 1 (Extended profile).

Fixes #925 (Extended profile), related to #948.

Made-with: Cursor
This commit is contained in:
Philipp
2026-04-27 18:13:56 +02:00
parent dd32514938
commit 73b5952c1d
2 changed files with 88 additions and 1 deletions

View File

@@ -306,7 +306,8 @@ public class ZUGFeRD2PullProvider implements IXMLProvider {
String chargeIndicator = "false";
if ((allowance.getPercent() != null) && (profile == Profiles.getByName("Extended"))) {
percentage = "<ram:CalculationPercent>" + vatFormat(allowance.getPercent()) + "</ram:CalculationPercent>";
percentage += "<ram:BasisAmount>" + currencyFormat(item.getValue()) + "</ram:BasisAmount>";
// BT-137/BT-142: BasisAmount = the value the percentage is applied to = line subtotal (price/basisQty)*qty
percentage += "<ram:BasisAmount>" + currencyFormat(item.getValue().multiply(item.getQuantity())) + "</ram:BasisAmount>";
}
if (allowance.isCharge()) {
chargeIndicator = "true";

View File

@@ -559,6 +559,92 @@ public class CalculationTest extends ResourceCase {
assertEquals(new BigDecimal("55.00"), lc.getItemTotalNetAmount());
}
@Test
public void testLineLevelAllowanceBasisAmountIsLineSubtotal() {
// BT-137: line-allowance BasisAmount = (price / basisQty) * qty (line subtotal), NOT the per-unit value.
// price=128.49 per 100 LTR, qty=50 LTR, basisQty=100
// line subtotal before allowance = 128.49 / 100 * 50 = 64.245 -> 64.25 (HALF_UP, BR-DEC-25)
// allowance ActualAmount = 10% of 64.245 = 6.4245 -> 6.42
SimpleDateFormat sqlDate = new SimpleDateFormat("yyyy-MM-dd");
Invoice invoice = new Invoice();
invoice.setDocumentName("Rechnung");
invoice.setNumber("BT137-ALLOWANCE");
try {
invoice.setIssueDate(sqlDate.parse("2024-01-01"));
invoice.setDueDate(sqlDate.parse("2024-01-31"));
} catch (Exception e) {
LOGGER.error("Failed to set dates", e);
}
TradeParty sender = new TradeParty("Sender GmbH", "Hauptstr. 1", "10115", "Berlin", "DE");
sender.addVATID("DE123456789");
invoice.setSender(sender);
TradeParty recipient = new TradeParty("Recipient GmbH", "Nebenstr. 2", "10116", "Berlin", "DE");
recipient.addVATID("DE987654321");
invoice.setRecipient(recipient);
Product product = new Product("Testartikel", "", "LTR", BigDecimal.ZERO);
Item item = new Item(product, new BigDecimal("128.49"), new BigDecimal("50"));
item.setBasisQuantity(new BigDecimal("100"));
item.addAllowance(new Allowance().setPercent(new BigDecimal(10)).setTaxPercent(BigDecimal.ZERO));
invoice.addItem(item);
ZUGFeRD2PullProvider zf2p = new ZUGFeRD2PullProvider();
zf2p.setProfile(Profiles.getByName("Extended"));
zf2p.generateXML(invoice);
String theXML = new String(zf2p.getXML(), StandardCharsets.UTF_8);
assertThat(theXML).valueByXPath("//*[local-name()='SpecifiedTradeAllowanceCharge'][*[local-name()='ChargeIndicator']/*[local-name()='Indicator']='false']/*[local-name()='BasisAmount']")
.asString()
.isEqualTo("64.25"); // (128.49/100)*50 = 64.245 rounded HALF_UP
assertThat(theXML).valueByXPath("//*[local-name()='SpecifiedTradeAllowanceCharge'][*[local-name()='ChargeIndicator']/*[local-name()='Indicator']='false']/*[local-name()='ActualAmount']")
.asString()
.isEqualTo("6.42"); // 64.245 * 0.10 = 6.4245 rounded HALF_UP
}
@Test
public void testLineLevelChargeBasisAmountIsLineSubtotal() {
// BT-142: line-charge BasisAmount = (price / basisQty) * qty (line subtotal), NOT the per-unit value.
// price=200.00 per 4 units, qty=10, basisQty=4
// line subtotal before charge = 200.00 / 4 * 10 = 500.00
// charge ActualAmount = 5% of 500.00 = 25.00
SimpleDateFormat sqlDate = new SimpleDateFormat("yyyy-MM-dd");
Invoice invoice = new Invoice();
invoice.setDocumentName("Rechnung");
invoice.setNumber("BT142-CHARGE");
try {
invoice.setIssueDate(sqlDate.parse("2024-01-01"));
invoice.setDueDate(sqlDate.parse("2024-01-31"));
} catch (Exception e) {
LOGGER.error("Failed to set dates", e);
}
TradeParty sender = new TradeParty("Sender GmbH", "Hauptstr. 1", "10115", "Berlin", "DE");
sender.addVATID("DE123456789");
invoice.setSender(sender);
TradeParty recipient = new TradeParty("Recipient GmbH", "Nebenstr. 2", "10116", "Berlin", "DE");
recipient.addVATID("DE987654321");
invoice.setRecipient(recipient);
Product product = new Product("Testartikel", "", "H87", BigDecimal.ZERO);
Item item = new Item(product, new BigDecimal("200.00"), new BigDecimal("10"));
item.setBasisQuantity(new BigDecimal("4"));
item.addCharge(new Charge().setPercent(new BigDecimal(5)).setTaxPercent(BigDecimal.ZERO));
invoice.addItem(item);
ZUGFeRD2PullProvider zf2p = new ZUGFeRD2PullProvider();
zf2p.setProfile(Profiles.getByName("Extended"));
zf2p.generateXML(invoice);
String theXML = new String(zf2p.getXML(), StandardCharsets.UTF_8);
assertThat(theXML).valueByXPath("//*[local-name()='SpecifiedTradeAllowanceCharge'][*[local-name()='ChargeIndicator']/*[local-name()='Indicator']='true']/*[local-name()='BasisAmount']")
.asString()
.isEqualTo("500.00"); // (200.00/4)*10 = 500.00
assertThat(theXML).valueByXPath("//*[local-name()='SpecifiedTradeAllowanceCharge'][*[local-name()='ChargeIndicator']/*[local-name()='Indicator']='true']/*[local-name()='ActualAmount']")
.asString()
.isEqualTo("25.00"); // 500.00 * 0.05 = 25.00
}
/**
* LineCalculator should not throw an exception when calculating a non-terminating decimal expansion
*/