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:
@@ -306,7 +306,8 @@ public class ZUGFeRD2PullProvider implements IXMLProvider {
|
|||||||
String chargeIndicator = "false";
|
String chargeIndicator = "false";
|
||||||
if ((allowance.getPercent() != null) && (profile == Profiles.getByName("Extended"))) {
|
if ((allowance.getPercent() != null) && (profile == Profiles.getByName("Extended"))) {
|
||||||
percentage = "<ram:CalculationPercent>" + vatFormat(allowance.getPercent()) + "</ram:CalculationPercent>";
|
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()) {
|
if (allowance.isCharge()) {
|
||||||
chargeIndicator = "true";
|
chargeIndicator = "true";
|
||||||
|
|||||||
@@ -559,6 +559,92 @@ public class CalculationTest extends ResourceCase {
|
|||||||
assertEquals(new BigDecimal("55.00"), lc.getItemTotalNetAmount());
|
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
|
* LineCalculator should not throw an exception when calculating a non-terminating decimal expansion
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user