Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions services/platform/apps/billing/efactura/xml_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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}"
Expand Down
87 changes: 87 additions & 0 deletions services/platform/tests/billing/efactura/test_xml_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
UBLInvoiceBuilder,
XMLBuilderError,
)
from apps.billing.payment_models import Payment
from tests.factories import InvoiceLineFactory


@override_settings(
Expand Down Expand Up @@ -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",
Expand Down
Loading