Render allowance and charge in UBL invoice

This commit is contained in:
Cédric Krier
2026-01-26 15:38:06 +01:00
parent 8f08fcf6bf
commit 19d4b91df2
6 changed files with 82 additions and 28 deletions
+1
View File
@@ -1,3 +1,4 @@
* Render allowance and charge
* Fill buyer's item identification
Version 7.8.0 - 2025-12-15
+27 -1
View File
@@ -30,6 +30,9 @@ from .exceptions import InvoiceError
from .party import ISO6523_TYPES
ISO6523 = {v: k for k, v in ISO6523_TYPES.items()}
PEPPOL_ALLOWANCES = {
'41', '42', '60', '62', '63', '64', '65', '66', '67', '68', '70', '71',
'88', '95', '100', '102', '103', '104', '105'}
if not hasattr(ASTCodeGenerator, 'visit_NameConstant'):
def visit_NameConstant(self, node):
@@ -217,7 +220,30 @@ class Invoice(Model):
@cached_property
def lines(self):
return [l for l in self.invoice.lines if l.type == 'line']
return [
l for l in self.invoice.lines
if l.type == 'line' and not self.allowance_charge_code(l)]
@cached_property
def allowance_charge(self):
return [
l for l in self.invoice.lines
if l.type == 'line' and self.allowance_charge_code(l)]
@classmethod
def allowance_charge_code(cls, line, specification=None):
if product := line.product:
sequence_type = line.invoice.sequence_type
if ((line.quantity > 0 and sequence_type == 'invoice')
or (line.quantity < 0 and sequence_type == 'credit_note')):
return product.unece_special_service_code
elif ((line.quantity < 0 and sequence_type == 'invoice')
or (line.quantity < 0 and sequence_type == 'credit_note')):
if (specification or '').startswith('peppol'):
if (product.unece_allowance_charge_code
not in PEPPOL_ALLOWANCES):
return
return product.unece_allowance_charge_code
@classmethod
def parse(cls, document):
+13 -10
View File
@@ -65,6 +65,17 @@ this repository contains the full copyright notices and license terms. -->
</cac:PaymentTerms>
</py:otherwise>
</py:choose>
<cac:AllowanceCharge py:for="allowance_charge in this.allowance_charge">
<cbc:ChargeIndicator>${'false' if allowance_charge.quantity > 0 else 'true'}</cbc:ChargeIndicator>
<py:with vars="reason_code=this.allowance_charge_code(allowance_charge, specification=specification)">
<cbc:AllowanceChargeReasonCode py:if="reason_code">${reason_code}</cbc:AllowanceChargeReasonCode>
</py:with>
<cbc:AllowanceChargeReason>${allowance_charge.product.name}</cbc:AllowanceChargeReason>
<cbc:Amount py:attrs="{'currencyID': this.invoice.currency.code}">${abs(allowance_charge.amount)}</cbc:Amount>
<cac:TaxCategory py:for="tax in allowance_charge.taxes">
${TaxCategory(tax)}
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal py:for="group, lines, amount in this.taxes">
<cbc:TaxAmount py:attrs="{'currencyID': this.invoice.currency.code}">${-amount}</cbc:TaxAmount>
<cac:TaxSubtotal py:for="line in lines">
@@ -72,11 +83,7 @@ this repository contains the full copyright notices and license terms. -->
<cbc:TaxAmount py:attrs="{'currencyID': this.invoice.currency.code}">${-line.amount}</cbc:TaxAmount>
<cbc:Percent py:if="line.tax.type == 'percentage' and not (specification or '').startswith('peppol')">${format((line.tax.rate * 100).normalize(), 'f')}</cbc:Percent>
<cac:TaxCategory>
<cbc:ID py:if="line.tax.unece_category_code">${line.tax.unece_category_code}</cbc:ID>
<cbc:Percent py:if="line.tax.type == 'percentage'">${format((line.tax.rate * 100).normalize(), 'f')}</cbc:Percent>
<cac:TaxScheme py:if="line.tax.unece_code">
<cbc:ID>${line.tax.unece_code}</cbc:ID>
</cac:TaxScheme>
${TaxCategory(line.tax)}
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
@@ -110,11 +117,7 @@ this repository contains the full copyright notices and license terms. -->
</py:with>
</py:if>
<cac:ClassifiedTaxCategory py:for="tax in line.taxes">
<cbc:ID py:if="tax.unece_category_code">${tax.unece_category_code}</cbc:ID>
<cbc:Percent py:if="tax.type == 'percentage'">${format((tax.rate * 100).normalize(), 'f')}</cbc:Percent>
<cac:TaxScheme py:if="tax.unece_code">
<cbc:ID>${tax.unece_code}</cbc:ID>
</cac:TaxScheme>
${TaxCategory(tax)}
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
+13 -10
View File
@@ -65,6 +65,17 @@ this repository contains the full copyright notices and license terms. -->
</cac:PaymentTerms>
</py:otherwise>
</py:choose>
<cac:AllowanceCharge py:for="allowance_charge in this.allowance_charge">
<cbc:ChargeIndicator>${'true' if allowance_charge.quantity > 0 else 'false'}</cbc:ChargeIndicator>
<py:with vars="reason_code=this.allowance_charge_code(allowance_charge, specification=specification)">
<cbc:AllowanceChargeReasonCode py:if="reason_code">${reason_code}</cbc:AllowanceChargeReasonCode>
</py:with>
<cbc:AllowanceChargeReason>${allowance_charge.product.name}</cbc:AllowanceChargeReason>
<cbc:Amount py:attrs="{'currencyID': this.invoice.currency.code}">${abs(allowance_charge.amount)}</cbc:Amount>
<cac:TaxCategory py:for="tax in allowance_charge.taxes">
${TaxCategory(tax)}
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal py:for="group, lines, amount in this.taxes">
<cbc:TaxAmount py:attrs="{'currencyID': this.invoice.currency.code}">${amount}</cbc:TaxAmount>
<cac:TaxSubtotal py:for="line in lines">
@@ -72,11 +83,7 @@ this repository contains the full copyright notices and license terms. -->
<cbc:TaxAmount py:attrs="{'currencyID': this.invoice.currency.code}">${line.amount}</cbc:TaxAmount>
<cbc:Percent py:if="line.tax.type == 'percentage' and not (specification or '').startswith('peppol')">${format((line.tax.rate * 100).normalize(), 'f')}</cbc:Percent>
<cac:TaxCategory>
<cbc:ID py:if="line.tax.unece_category_code">${line.tax.unece_category_code}</cbc:ID>
<cbc:Percent py:if="line.tax.type == 'percentage'">${format((line.tax.rate * 100).normalize(), 'f')}</cbc:Percent>
<cac:TaxScheme py:if="line.tax.unece_code">
<cbc:ID>${line.tax.unece_code}</cbc:ID>
</cac:TaxScheme>
${TaxCategory(line.tax)}
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
@@ -110,11 +117,7 @@ this repository contains the full copyright notices and license terms. -->
</py:with>
</py:if>
<cac:ClassifiedTaxCategory py:for="tax in line.taxes">
<cbc:ID py:if="tax.unece_category_code">${tax.unece_category_code}</cbc:ID>
<cbc:Percent py:if="tax.type == 'percentage'">${format((tax.rate * 100).normalize(), 'f')}</cbc:Percent>
<cac:TaxScheme py:if="tax.unece_code">
<cbc:ID>${tax.unece_code}</cbc:ID>
</cac:TaxScheme>
${TaxCategory(tax)}
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
@@ -77,4 +77,11 @@ this repository contains the full copyright notices and license terms. -->
<cbc:Name py:if="not (specification or '').startswith('peppol')">${address.country.name}</cbc:Name>
</cac:Country>
</py:def>
<py:def function="TaxCategory(tax)">
<cbc:ID py:if="tax.unece_category_code">${tax.unece_category_code}</cbc:ID>
<cbc:Percent py:if="tax.type == 'percentage'">${format((tax.rate * 100).normalize(), 'f')}</cbc:Percent>
<cac:TaxScheme py:if="tax.unece_code">
<cbc:ID>${tax.unece_code}</cbc:ID>
</cac:TaxScheme>
</py:def>
</py:strip>
+21 -7
View File
@@ -79,16 +79,22 @@ def get_invoice():
type='percentage',
rate=Decimal('.1'),
),
base=Decimal('100.00'),
amount=Decimal('10.00'),
base=Decimal('105.00'),
amount=Decimal('10.50'),
legal_notice="Legal Notice",
)]
product = Mock(spec=Product,
code="12345",
type='service',
unece_special_service_code=None,
)
product.name = "Product"
product.identifier_get.return_value = '98412345678908'
charge = Mock(spec=Product,
type='service',
unece_special_service_code='ZZZ',
)
charge.name = "Charge"
lines = [Mock(spec=InvoiceLine,
type='line',
product=product,
@@ -99,6 +105,12 @@ def get_invoice():
amount=Decimal('100.00'),
description="Description",
taxes=[t.tax for t in taxes],
), Mock(spec=Invoice,
type='line',
product=charge,
quantity=1,
amount=Decimal('5.00'),
taxes=[t.tax for t in taxes],
)]
invoice = MagicMock(spec=Invoice,
id=-1,
@@ -120,17 +132,19 @@ def get_invoice():
lines=lines,
line_lines=lines,
taxes=taxes,
untaxed_amount=Decimal('100.00'),
tax_amount=Decimal('10.00'),
total_amount=Decimal('110.00'),
amount_to_pay=Decimal('110.00'),
untaxed_amount=Decimal('105.00'),
tax_amount=Decimal('10.50'),
total_amount=Decimal('110.50'),
amount_to_pay=Decimal('110.50'),
lines_to_pay=[Mock(spec=MoveLine,
maturity_date=datetime.date.today(),
amount=Decimal('110.00'),
amount=Decimal('110.50'),
)],
sales=[],
state='posted',
)
for line in invoice.lines:
line.invoice = invoice
return invoice