diff --git a/services/platform/apps/billing/efactura/xml_builder.py b/services/platform/apps/billing/efactura/xml_builder.py index 7e3e7d2b..c4f89ccd 100644 --- a/services/platform/apps/billing/efactura/xml_builder.py +++ b/services/platform/apps/billing/efactura/xml_builder.py @@ -585,8 +585,18 @@ def _add_legal_monetary_total(self) -> None: tax_incl = self._add_cbc(monetary_total, "TaxInclusiveAmount", self._format_amount(total)) tax_incl.set("currencyID", currency) - # Payable Amount (amount to be paid) - payable = self._add_cbc(monetary_total, "PayableAmount", self._format_amount(total)) + # BT-113 PrepaidAmount + BT-115 PayableAmount: subtract already-collected payments + # so a partially-paid invoice reports the correct balance due. + # Per EN16931: PayableAmount = TaxInclusiveAmount - PrepaidAmount. + remaining_cents = self.invoice.get_remaining_amount() + prepaid_cents = max(0, (self.invoice.total_cents or 0) - remaining_cents) + if prepaid_cents > 0: + prepaid_amount = Decimal(prepaid_cents) / 100 + prepaid_elem = self._add_cbc(monetary_total, "PrepaidAmount", self._format_amount(prepaid_amount)) + prepaid_elem.set("currencyID", currency) + + payable_amount = Decimal(remaining_cents) / 100 + payable = self._add_cbc(monetary_total, "PayableAmount", self._format_amount(payable_amount)) payable.set("currencyID", currency) def _add_invoice_lines(self) -> None: @@ -610,9 +620,11 @@ def _add_invoice_line(self, line_id: int, line: InvoiceLine) -> None: quantity_elem = self._add_cbc(invoice_line, "InvoicedQuantity", self._format_quantity(quantity)) quantity_elem.set("unitCode", self._get_unit_code(line)) - # Line Extension Amount (quantity * unit price, without tax) + # BT-131: LineExtensionAmount must be NET of line-level allowances (EN16931 BR-CO-10). + # Invoice.subtotal_cents is already net, so the line sum must match. unit_price = Decimal(line.unit_price_cents or 0) / 100 - line_amount = unit_price * Decimal(str(quantity)) + discount_amount = Decimal(line.discount_amount_cents or 0) / 100 + line_amount = unit_price * Decimal(str(quantity)) - discount_amount line_ext = self._add_cbc(invoice_line, "LineExtensionAmount", self._format_amount(line_amount)) line_ext.set("currencyID", self.invoice.currency.code) @@ -622,11 +634,12 @@ def _add_invoice_line(self, line_id: int, line: InvoiceLine) -> None: self._add_cbc(period, "StartDate", line.period_start.isoformat()) self._add_cbc(period, "EndDate", line.period_end.isoformat()) - # BT-147: Line-level discount (AllowanceCharge) + # BT-136/BT-137/BT-140: Line-level AllowanceCharge with reason (EN16931 BR-42) if line.discount_amount_cents and line.discount_amount_cents > 0: allowance = self._add_cac(invoice_line, "AllowanceCharge") self._add_cbc(allowance, "ChargeIndicator", "false") - discount_amount = Decimal(line.discount_amount_cents) / 100 + self._add_cbc(allowance, "AllowanceChargeReasonCode", "95") # UNTDID 5189: Discount + self._add_cbc(allowance, "AllowanceChargeReason", "Discount") amount_elem = self._add_cbc(allowance, "Amount", self._format_amount(discount_amount)) amount_elem.set("currencyID", self.invoice.currency.code) @@ -856,7 +869,16 @@ def _add_legal_monetary_total(self) -> None: tax_incl = self._add_cbc(monetary_total, "TaxInclusiveAmount", self._format_amount(total)) tax_incl.set("currencyID", currency) - payable = self._add_cbc(monetary_total, "PayableAmount", self._format_amount(total)) + # BT-113/BT-115: subtract already-collected payments so partial payments are honoured. + remaining_cents = self.invoice.get_remaining_amount() + prepaid_cents = max(0, (self.invoice.total_cents or 0) - remaining_cents) + if prepaid_cents > 0: + prepaid_amount = Decimal(prepaid_cents) / 100 + prepaid_elem = self._add_cbc(monetary_total, "PrepaidAmount", self._format_amount(prepaid_amount)) + prepaid_elem.set("currencyID", currency) + + payable_amount = Decimal(remaining_cents) / 100 + payable = self._add_cbc(monetary_total, "PayableAmount", self._format_amount(payable_amount)) payable.set("currencyID", currency) def _add_credit_note_lines(self) -> None: @@ -874,11 +896,21 @@ def _add_credit_note_line(self, line_id: int, line: InvoiceLine) -> None: quantity_elem = self._add_cbc(cn_line, "CreditedQuantity", self._format_quantity(quantity)) quantity_elem.set("unitCode", UNIT_CODE_PIECE) + # BT-131 net: line LineExtensionAmount must be net of line-level allowances. unit_price = Decimal(line.unit_price_cents or 0) / 100 - line_amount = unit_price * Decimal(str(quantity)) + discount_amount = Decimal(line.discount_amount_cents or 0) / 100 + line_amount = unit_price * Decimal(str(quantity)) - discount_amount line_ext = self._add_cbc(cn_line, "LineExtensionAmount", self._format_amount(line_amount)) line_ext.set("currencyID", self.invoice.currency.code) + if line.discount_amount_cents and line.discount_amount_cents > 0: + allowance = self._add_cac(cn_line, "AllowanceCharge") + self._add_cbc(allowance, "ChargeIndicator", "false") + self._add_cbc(allowance, "AllowanceChargeReasonCode", "95") # UNTDID 5189: Discount + self._add_cbc(allowance, "AllowanceChargeReason", "Discount") + amount_elem = self._add_cbc(allowance, "Amount", self._format_amount(discount_amount)) + amount_elem.set("currencyID", self.invoice.currency.code) + # Item item = self._add_cac(cn_line, "Item") description = line.description or f"Credit for item {line.id}" diff --git a/services/platform/tests/billing/efactura/test_xml_builder.py b/services/platform/tests/billing/efactura/test_xml_builder.py index 6c49f72a..11f983ae 100644 --- a/services/platform/tests/billing/efactura/test_xml_builder.py +++ b/services/platform/tests/billing/efactura/test_xml_builder.py @@ -17,6 +17,8 @@ UBLInvoiceBuilder, XMLBuilderError, ) +from apps.billing.payment_models import Payment +from tests.factories import InvoiceLineFactory @override_settings( @@ -261,6 +263,91 @@ def test_validation_error_missing_supplier_config(self): self.assertIn("Supplier company name not configured", str(context.exception)) + def test_line_discount_emits_allowance_with_reason_code(self): + """EN16931 BR-42: line-level AllowanceCharge must carry BT-140 reason code.""" + self.line.delete() + InvoiceLineFactory( + invoice=self.invoice, + description="Discounted item", + unit_price_cents=100000, + quantity=1, + tax_rate=Decimal("0.1900"), + discount_amount_cents=10000, + ) + + builder = UBLInvoiceBuilder(self.invoice) + xml = builder.build() + doc = etree.fromstring(xml.encode()) + + invoice_line = doc.find(f".//{{{NAMESPACES['cac']}}}InvoiceLine") + allowance = invoice_line.find(f"{{{NAMESPACES['cac']}}}AllowanceCharge") + self.assertIsNotNone(allowance) + + reason_code = allowance.find(f"{{{NAMESPACES['cbc']}}}AllowanceChargeReasonCode") + self.assertIsNotNone(reason_code) + self.assertEqual(reason_code.text, "95") + + reason = allowance.find(f"{{{NAMESPACES['cbc']}}}AllowanceChargeReason") + self.assertIsNotNone(reason) + self.assertEqual(reason.text, "Discount") + + def test_line_extension_amount_is_net_of_discount(self): + """EN16931 BT-131: LineExtensionAmount must be net of line-level allowances.""" + self.line.delete() + InvoiceLineFactory( + invoice=self.invoice, + description="Discounted item", + unit_price_cents=100000, + quantity=1, + tax_rate=Decimal("0.1900"), + discount_amount_cents=10000, + ) + + builder = UBLInvoiceBuilder(self.invoice) + xml = builder.build() + doc = etree.fromstring(xml.encode()) + + invoice_line = doc.find(f".//{{{NAMESPACES['cac']}}}InvoiceLine") + line_ext = invoice_line.find(f"{{{NAMESPACES['cbc']}}}LineExtensionAmount") + # 1000.00 unit_price - 100.00 discount = 900.00 net + self.assertEqual(Decimal(line_ext.text), Decimal("900.00")) + + def test_payable_amount_subtracts_prepaid_payments(self): + """EN16931 BT-113/BT-115: partially-paid invoice must report prepaid + remaining balance.""" + Payment.objects.create( + customer=self.customer, + invoice=self.invoice, + currency=self.currency, + amount_cents=50000, + status="succeeded", + ) + + builder = UBLInvoiceBuilder(self.invoice) + xml = builder.build() + doc = etree.fromstring(xml.encode()) + + monetary_total = doc.find(f".//{{{NAMESPACES['cac']}}}LegalMonetaryTotal") + + prepaid = monetary_total.find(f"{{{NAMESPACES['cbc']}}}PrepaidAmount") + self.assertIsNotNone(prepaid) + self.assertEqual(Decimal(prepaid.text), Decimal("500.00")) + + payable = monetary_total.find(f"{{{NAMESPACES['cbc']}}}PayableAmount") + # 1190.00 total - 500.00 prepaid = 690.00 due + self.assertEqual(Decimal(payable.text), Decimal("690.00")) + + def test_payable_amount_omits_prepaid_when_unpaid(self): + """A fully-unpaid invoice must not emit a PrepaidAmount element.""" + builder = UBLInvoiceBuilder(self.invoice) + xml = builder.build() + doc = etree.fromstring(xml.encode()) + + monetary_total = doc.find(f".//{{{NAMESPACES['cac']}}}LegalMonetaryTotal") + self.assertIsNone(monetary_total.find(f"{{{NAMESPACES['cbc']}}}PrepaidAmount")) + + payable = monetary_total.find(f"{{{NAMESPACES['cbc']}}}PayableAmount") + self.assertEqual(Decimal(payable.text), Decimal("1190.00")) + @override_settings( COMPANY_NAME="Test Company SRL",