details:   https://code.tryton.org/tryton/commit/69beb4452faa
branch:    default
user:      Cédric Krier <[email protected]>
date:      Mon Jan 26 15:38:06 2026 +0100
description:
        Render allowance and charge in UBL invoice
diffstat:

 modules/edocument_ubl/CHANGELOG                 |   1 +
 modules/edocument_ubl/edocument.py              |  28 ++++++++++++++++++++++++-
 modules/edocument_ubl/template/2/CreditNote.xml |  23 +++++++++++--------
 modules/edocument_ubl/template/2/Invoice.xml    |  23 +++++++++++--------
 modules/edocument_ubl/template/2/base.xml       |   7 ++++++
 modules/edocument_ubl/tests/test_module.py      |  28 ++++++++++++++++++------
 6 files changed, 82 insertions(+), 28 deletions(-)

diffs (228 lines):

diff -r 51d3e0bcd5fb -r 69beb4452faa modules/edocument_ubl/CHANGELOG
--- a/modules/edocument_ubl/CHANGELOG   Mon Jan 26 15:42:27 2026 +0100
+++ b/modules/edocument_ubl/CHANGELOG   Mon Jan 26 15:38:06 2026 +0100
@@ -1,3 +1,4 @@
+* Render allowance and charge
 * Fill buyer's item identification
 
 Version 7.8.0 - 2025-12-15
diff -r 51d3e0bcd5fb -r 69beb4452faa modules/edocument_ubl/edocument.py
--- a/modules/edocument_ubl/edocument.py        Mon Jan 26 15:42:27 2026 +0100
+++ b/modules/edocument_ubl/edocument.py        Mon Jan 26 15:38:06 2026 +0100
@@ -30,6 +30,9 @@
 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 @@
 
     @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):
diff -r 51d3e0bcd5fb -r 69beb4452faa 
modules/edocument_ubl/template/2/CreditNote.xml
--- a/modules/edocument_ubl/template/2/CreditNote.xml   Mon Jan 26 15:42:27 
2026 +0100
+++ b/modules/edocument_ubl/template/2/CreditNote.xml   Mon Jan 26 15:38:06 
2026 +0100
@@ -65,6 +65,17 @@
         </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 @@
         <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 @@
             </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>
diff -r 51d3e0bcd5fb -r 69beb4452faa 
modules/edocument_ubl/template/2/Invoice.xml
--- a/modules/edocument_ubl/template/2/Invoice.xml      Mon Jan 26 15:42:27 
2026 +0100
+++ b/modules/edocument_ubl/template/2/Invoice.xml      Mon Jan 26 15:38:06 
2026 +0100
@@ -65,6 +65,17 @@
         </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 @@
         <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 @@
             </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>
diff -r 51d3e0bcd5fb -r 69beb4452faa modules/edocument_ubl/template/2/base.xml
--- a/modules/edocument_ubl/template/2/base.xml Mon Jan 26 15:42:27 2026 +0100
+++ b/modules/edocument_ubl/template/2/base.xml Mon Jan 26 15:38:06 2026 +0100
@@ -77,4 +77,11 @@
             <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>
diff -r 51d3e0bcd5fb -r 69beb4452faa modules/edocument_ubl/tests/test_module.py
--- a/modules/edocument_ubl/tests/test_module.py        Mon Jan 26 15:42:27 
2026 +0100
+++ b/modules/edocument_ubl/tests/test_module.py        Mon Jan 26 15:38:06 
2026 +0100
@@ -79,16 +79,22 @@
                 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 @@
             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 @@
         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
 
 

Reply via email to