From 7c90b22b75e7eb56c605ddefa1f68d8f59cbaca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 30 Sep 2025 00:11:31 +0200 Subject: [PATCH] Add payment references to invoices --- modules/account_invoice/CHANGELOG | 1 + modules/account_invoice/account.py | 33 ++ modules/account_invoice/invoice.fodt | 553 ++++++++---------- modules/account_invoice/invoice.py | 128 ++++ modules/account_invoice/message.xml | 3 + modules/account_invoice/setup.py | 2 +- .../tests/scenario_invoice.rst | 5 +- .../tests/scenario_invoice_supplier.rst | 4 + modules/account_invoice/tryton.cfg | 1 + .../view/configuration_form.xml | 5 + modules/account_invoice/view/invoice_form.xml | 10 + modules/party/party.py | 15 + modules/party/tests/test_module.py | 2 + 13 files changed, 442 insertions(+), 320 deletions(-) diff --git a/modules/account_invoice/CHANGELOG b/modules/account_invoice/CHANGELOG index 8f6fe1499f..49fbf506f9 100644 --- a/modules/account_invoice/CHANGELOG +++ b/modules/account_invoice/CHANGELOG @@ -1,3 +1,4 @@ +* Add payment references to invoices Version 7.6.0 - 2025-04-28 -------------------------- diff --git a/modules/account_invoice/account.py b/modules/account_invoice/account.py index 56a76e13d7..661f30e17a 100644 --- a/modules/account_invoice/account.py +++ b/modules/account_invoice/account.py @@ -22,14 +22,34 @@ class Configuration(metaclass=PoolMeta): default_customer_payment_term = fields.MultiValue( fields.Many2One( 'account.invoice.payment_term', "Default Customer Payment Term")) + customer_payment_reference_number = fields.MultiValue( + fields.Selection('get_customer_payment_references', + "Customer Payment Reference Number", + help="The number used to generate " + "the customer payment reference.")) @classmethod def multivalue_model(cls, field): pool = Pool() if field in 'default_customer_payment_term': return pool.get('account.configuration.default_payment_term') + elif field == 'customer_payment_reference_number': + return pool.get('account.configuration.payment_reference') return super().multivalue_model(field) + @classmethod + def get_customer_payment_references(cls): + pool = Pool() + PaymentReference = pool.get('account.configuration.payment_reference') + field = 'customer_payment_reference_number' + return PaymentReference.fields_get([field])[field]['selection'] + + @classmethod + def default_customer_payment_reference_number(cls, **pattern): + pool = Pool() + PaymentReference = pool.get('account.configuration.payment_reference') + return PaymentReference.default_customer_payment_reference_number() + class ConfigurationDefaultPaymentTerm(ModelSQL, CompanyValueMixin): __name__ = 'account.configuration.default_payment_term' @@ -38,6 +58,19 @@ class ConfigurationDefaultPaymentTerm(ModelSQL, CompanyValueMixin): 'account.invoice.payment_term', "Default Customer Payment Term") +class ConfigurationPaymentReference(ModelSQL, CompanyValueMixin): + __name__ = 'account.configuration.payment_reference' + + customer_payment_reference_number = fields.Selection([ + ('invoice', "Invoice"), + ('party', "Party"), + ], "Customer Payment Reference Number") + + @classmethod + def default_customer_payment_reference_number(cls): + return 'invoice' + + class FiscalYear(metaclass=PoolMeta): __name__ = 'account.fiscalyear' invoice_sequences = fields.One2Many( diff --git a/modules/account_invoice/invoice.fodt b/modules/account_invoice/invoice.fodt index b04b907296..5416a84d34 100644 --- a/modules/account_invoice/invoice.fodt +++ b/modules/account_invoice/invoice.fodt @@ -1,24 +1,32 @@ - - LibreOffice/7.6.4.1$Linux_X86_64 LibreOffice_project/60$Build-12012-11-15T15:00:351P0D + + + LibreOffice/25.2.5.2$Linux_X86_64 LibreOffice_project/520$Build-2 + + + + + + + - 0 + 205105 0 - 25269 - 23973 + 25243 + 21883 true false view2 - 3840 - 9275 + 20849 + 217225 0 - 0 - 25268 - 23971 + 205105 + 25241 + 226986 0 1 false @@ -33,113 +41,119 @@ - true false - true - false - true - false - false - 0 - true - false - false - false - false - false - true - true - true - false + true + true + false + 0 + false + true + false + false + false + true + false + false + true + 0 + true + false + false + false + true false + true false - false false + false false true - true - false - false - false - false - false - false - true - false - false - 882034 - false - - true - false - false - 3217126 - true - false - false - false - 1 - true - true - false - false - false - false - true - true - true - true - false - true - 0 - - true - true - false - false - - 0 - true - false - false - false - high-resolution - false - true - false - false - false - false - false - false - false - true - true + false + false + false + false + false + true false - true - false - - false - false - false - false + false + true + true + false + false + false + false false - false - false - false - false - false - false - false - false - false - 0 - true - false - + false + high-resolution + false + 882034 + false true + false + + + true + + false + false + false + true + true + true + false + 0 + true + false + false + true + true + false + false + true + false + false + false + false + false + false + false + false + 0 + false + true + false + false + false + true + false + false + true + 1 + false + false + false + false + false + false + false + false + false + false + false + false + true + false + false + false + false + + true + 3274672 + true + false + false @@ -169,7 +183,7 @@ - + @@ -181,7 +195,7 @@ - + @@ -200,7 +214,7 @@ - + @@ -211,10 +225,10 @@ - + - + @@ -488,74 +502,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -598,6 +544,9 @@ + + + @@ -613,119 +562,77 @@ - - - + + + - - - + + + - - - + + + - + + - - - + + - - - + + - - - + + - - + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + + - + + + + + + + + + + + + + + + + + - - + + @@ -787,34 +694,34 @@ - <replace text:p="set_lang(invoice.party.lang)"> - <replace text:p="invoice.set_lang(invoice.party.lang)"> - <for each="line in invoice.invoice_address.full_address.split('\n')"> - <line> - </for> - <if test="invoice.party_tax_identifier"> - <invoice.party_tax_identifier.type_string>: <invoice.party_tax_identifier.code> - </if> + <replace text:p="set_lang(invoice.party.lang)"> + <replace text:p="invoice.set_lang(invoice.party.lang)"> + <for each="line in invoice.invoice_address.full_address.split('\n')"> + <line> + </for> + <if test="invoice.party_tax_identifier"> + <invoice.party_tax_identifier.type_string>: <invoice.party_tax_identifier.code> + </if> <if test="invoice.type == 'in'"> - Supplier Invoice N°:<invoice.number and ' ' + invoice.number or ''> + Supplier Invoice N°:<invoice.number and ' ' + invoice.number or ''> </if> <if test="invoice.type == 'out'"> <choose test=""> <when test="invoice.state == 'draft'"> - Draft Invoice + Draft Invoice </when> <when test="invoice.state == 'validated'"> - Pro forma Invoice + Pro forma Invoice </when> <otherwise test=""> - Invoice N°:<invoice.number and ' ' + invoice.number or ''> + Invoice N°:<invoice.number and ' ' + invoice.number or ''> </otherwise> </choose> </if> - Description: <invoice.description or ''> - Reference: <invoice.origins or ''><', ' if (invoice.reference and invoice.origins) else ''><invoice.reference or ''> + Description: <invoice.description or ''> + Reference: <invoice.origins or ''><', ' if (invoice.reference and invoice.origins) else ''><invoice.reference or ''> Date: <format_date(invoice.invoice_date or today, invoice.party.lang)> - <if test="invoice.tax_identifier"> + <if test="invoice.tax_identifier"> <invoice.tax_identifier.type_string>: <invoice.tax_identifier.code> </if> @@ -825,19 +732,19 @@ - Description + Description - Quantity + Quantity - Unit Price + Unit Price - Taxes + Taxes - Amount + Amount @@ -881,16 +788,16 @@ </if> - <format_number_symbol(line.quantity, invoice.party.lang, line.unit, digits=line.unit.digits) if line.unit else format_number(line.quantity, invoice.party.lang)> + <format_number_symbol(line.quantity, invoice.party.lang, line.unit, digits=line.unit.digits) if line.unit else format_number(line.quantity, invoice.party.lang)> - <format_currency(line.unit_price, invoice.party.lang, invoice.currency, digits=line.__class__.unit_price.digits[1])> + <format_currency(line.unit_price, invoice.party.lang, invoice.currency, digits=line.__class__.unit_price.digits[1])> - <','.join('[' + '%s' % x.sequence_number + ']' for x in line.invoice_taxes)> + <','.join('[' + '%s' % x.sequence_number + ']' for x in line.invoice_taxes)> - <format_currency(line.amount, invoice.party.lang, invoice.currency)> + <format_currency(line.amount, invoice.party.lang, invoice.currency)> @@ -911,6 +818,7 @@ + <for each="description in (line.description or '').split('\n')"> @@ -921,7 +829,7 @@ - <format_currency(line.amount, invoice.party.lang, invoice.currency)> + <format_currency(line.amount, invoice.party.lang, invoice.currency)> @@ -945,7 +853,7 @@ <for each="description in (line.description or '').split('\n')"> - <description> + <description> </for> @@ -1022,13 +930,13 @@ - Tax + Tax - Base + Base - Amount + Amount @@ -1044,31 +952,31 @@ <'[' + '%s' % tax.sequence_number + ']'><tax.description or ''> - <format_currency(tax.base, invoice.party.lang, invoice.currency)> + <format_currency(tax.base, invoice.party.lang, invoice.currency)> - <format_currency(tax.amount, invoice.party.lang, invoice.currency)> + <format_currency(tax.amount, invoice.party.lang, invoice.currency)> - <if test="tax.legal_notice"> + <if test="tax.legal_notice"> - <for each="line in tax.legal_notice.split('\n')"> - <line> - </for> + <for each="line in tax.legal_notice.split('\n')"> + <line> + </for> - </if> + </if> @@ -1089,30 +997,30 @@ - Total (excl. taxes): + Total (excl. taxes): - <format_currency(invoice.untaxed_amount, invoice.party.lang, invoice.currency)> + <format_currency(invoice.untaxed_amount, invoice.party.lang, invoice.currency)> - Taxes: + Taxes: - <format_currency(invoice.tax_amount, invoice.party.lang, invoice.currency)> + <format_currency(invoice.tax_amount, invoice.party.lang, invoice.currency)> - Total: + Total: - <format_currency(invoice.total_amount, invoice.party.lang, invoice.currency)> + <format_currency(invoice.total_amount, invoice.party.lang, invoice.currency)> - + @@ -1123,7 +1031,7 @@ - Payment Term + Payment Term <if test="invoice.payment_term and invoice.payment_term.description"> <for each="description in (invoice.payment_term.description or '').split('\n')"> <description> @@ -1134,10 +1042,10 @@ - Date + Date - Amount + Amount @@ -1155,10 +1063,10 @@ - <format_date(line.maturity_date, invoice.party.lang) if line.maturity_date else ''> + <format_date(line.maturity_date, invoice.party.lang) if line.maturity_date else ''> - <line.amount_second_currency and format_currency(line.amount_second_currency, invoice.party.lang, invoice.currency) or format_currency(line.debit - line.credit, invoice.party.lang, invoice.currency)> + <line.amount_second_currency and format_currency(line.amount_second_currency, invoice.party.lang, invoice.currency) or format_currency(line.debit - line.credit, invoice.party.lang, invoice.currency)> @@ -1173,9 +1081,18 @@ + + + <if test="invoice.type == 'out'"> + Reference + <invoice.customer_payment_reference> + </if> + + + </if> - <for each="comment in (invoice.comment or '').split('\n')"> + <for each="comment in (invoice.comment or '').split('\n')"> <comment> </for> diff --git a/modules/account_invoice/invoice.py b/modules/account_invoice/invoice.py index 49ea2d85b9..e654b45dd4 100644 --- a/modules/account_invoice/invoice.py +++ b/modules/account_invoice/invoice.py @@ -1,16 +1,19 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import datetime as dt +import re from collections import defaultdict from decimal import Decimal from itertools import chain, groupby +import stdnum.exceptions from genshi.template.text import TextTemplate from sql import Null from sql.aggregate import Sum from sql.conditionals import Coalesce from sql.functions import CharLength, Round from sql.operators import Exists +from stdnum import iso7064, iso11649 from trytond import backend, config from trytond.i18n import gettext @@ -101,6 +104,8 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): }) type_name = fields.Function(fields.Char('Type'), 'get_type_name') number = fields.Char("Number", readonly=True) + number_alnum = fields.Char("Number Alphanumeric", readonly=True) + number_digit = fields.Integer("Number Digit", readonly=True) reference = fields.Char( "Reference", states={ @@ -142,6 +147,27 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): "Payment Term Date", states=_states, help="The date from which the payment term is calculated.\n" "Leave empty to use the invoice date.") + supplier_payment_reference_type = fields.Selection([ + (None, ""), + ('creditor_reference', "Creditor Reference"), + ], "Payment Reference Type", + states={ + 'invisible': Eval('type', 'out') != 'in', + }) + supplier_payment_reference_type_string = ( + supplier_payment_reference_type.translated( + 'supplier_payment_reference_type')) + supplier_payment_reference = fields.Char( + "Payment Reference", + states={ + 'invisible': Eval('type', 'out') != 'in', + }) + customer_payment_reference = fields.Function(fields.Char( + "Customer Payment Reference", + states={ + 'invisible': Eval('type', 'out') != 'out', + }), + 'get_customer_payment_reference') sequence = fields.Integer("Sequence", readonly=True) sequence_type_cache = fields.Selection([ (None, ""), @@ -609,6 +635,59 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): with Transaction().set_context(company=company_id): return self.invoice_date or Date.today() + @fields.depends( + 'supplier_payment_reference_type', + 'supplier_payment_reference') + def on_change_with_supplier_payment_reference(self): + reference = self.supplier_payment_reference + if reference and (type := self.supplier_payment_reference_type): + reference = getattr( + self, f'_format_supplier_payment_reference_{type}')(reference) + return reference + + @classmethod + def _format_supplier_payment_reference_creditor_reference(cls, reference): + try: + return iso11649.format(reference) + except stdnum.exceptions.ValidationError: + return reference + + def get_customer_payment_reference(self, name): + return self.format_customer_payment_reference( + self._customer_payment_reference_type()) + + def _customer_payment_reference_type(self): + return 'creditor_reference' + + def _customer_payment_reference_number_alnum(self, source): + if source == 'invoice': + return self.number_alnum + elif source == 'party': + return self.party.code_alnum + + def _customer_payment_reference_number_digit(self, source): + if source == 'invoice': + return self.number_digit + elif source == 'party': + return self.party.code_digit + + def format_customer_payment_reference(self, type): + pool = Pool() + Confifugaration = pool.get('account.configuration') + configuration = Confifugaration(1) + source = configuration.get_multivalue( + 'customer_payment_reference_number', + company=self.company.id) + return getattr( + self, f'_format_customer_payment_reference_{type}')(source) + + def _format_customer_payment_reference_creditor_reference(self, source): + number = self._customer_payment_reference_number_alnum(source) + if number: + number = number[-25:].upper() + check_digits = iso7064.mod_97_10.calc_check_digits(f'{number}RF') + return iso11649.format(f'RF{check_digits}{number}') + @fields.depends('party') def on_change_with_party_lang(self, name=None): Config = Pool().get('ir.configuration') @@ -1482,6 +1561,19 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): if draft_invoices := [i for i in invoices if i.state == 'draft']: cls.update_taxes(draft_invoices) + def compute_fields(self, field_names=None): + values = super().compute_fields(field_names=field_names) + if (not field_names or 'number' in field_names): + values['number_alnum'] = ( + re.sub(r'[\W_]', '', self.number) + if self.number is not None else None) + try: + values['number_digit'] = int( + re.sub(r'\D', '', self.number or '')) + except ValueError: + values['number_digit'] = None + return values + @classmethod def copy(cls, invoices, default=None): if default is None: @@ -1503,7 +1595,11 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): return [] default.setdefault('number', None) + default.setdefault('number_alnum', None) + default.setdefault('number_digit', None) default.setdefault('reference') + default.setdefault('supplier_payment_reference') + default.setdefault('supplier_payment_reference_type') default.setdefault('sequence') default.setdefault('move', None) default.setdefault('additional_moves', None) @@ -1546,6 +1642,38 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): '.msg_invoice_payment_lines_greater_amount', invoice=self.rec_name)) + @classmethod + def validate_fields(cls, invoices, field_names): + super().validate_fields(invoices, field_names) + cls.check_supplier_payment_reference(invoices, field_names) + + @classmethod + def check_supplier_payment_reference(cls, invoices, field_names): + if field_names and not ( + field_names & { + 'supplier_payment_reference', + 'supplier_payment_reference_type', + }): + return + + for invoice in invoices: + if type := invoice.supplier_payment_reference_type: + method = getattr( + cls, f'_check_supplier_payment_reference_{type}') + if not method(invoice): + reference = invoice.supplier_payment_reference + type = invoice.supplier_payment_reference_type_string + raise InvoiceValidationError( + gettext( + 'account_invoice.' + 'msg_invoice_supplier_payment_reference_invalid', + type=type, + reference=reference, + invoice=invoice.rec_name)) + + def _check_supplier_payment_reference_creditor_reference(self): + return iso11649.is_valid(self.supplier_payment_reference) + def get_reconcile_lines_for_amount(self, amount, currency, party=None): ''' Return list of lines and the remainder to make reconciliation. diff --git a/modules/account_invoice/message.xml b/modules/account_invoice/message.xml index 2ee9abfa30..b74cdc8cf9 100644 --- a/modules/account_invoice/message.xml +++ b/modules/account_invoice/message.xml @@ -42,6 +42,9 @@ this repository contains the full copyright notices and license terms. --> Payment lines amount on invoice "%(invoice)s" can not be greater than the invoice amount. + + The %(type)s "%(reference)s" on invoice "%(invoice)s" is not valid. + You cannot add/remove payment lines on paid invoice "%(invoice)s". diff --git a/modules/account_invoice/setup.py b/modules/account_invoice/setup.py index 86c7531e61..8267f0c5e3 100755 --- a/modules/account_invoice/setup.py +++ b/modules/account_invoice/setup.py @@ -44,7 +44,7 @@ else: download_url = 'http://downloads.tryton.org/%s.%s/' % ( major_version, minor_version) -requires = ['python-dateutil', 'python-sql >= 0.4'] +requires = ['python-dateutil', 'python-sql >= 0.4', 'python-stdnum >= 0.3'] for dep in info.get('depends', []): if not re.match(r'(ir|res)(\W|$)', dep): requires.append(get_require_version('trytond_%s' % dep)) diff --git a/modules/account_invoice/tests/scenario_invoice.rst b/modules/account_invoice/tests/scenario_invoice.rst index 0b3af4a332..6dffa295f2 100644 --- a/modules/account_invoice/tests/scenario_invoice.rst +++ b/modules/account_invoice/tests/scenario_invoice.rst @@ -7,13 +7,15 @@ Imports:: >>> import datetime as dt >>> from decimal import Decimal + >>> from stdnum import iso11649 + >>> from proteus import Model, Wizard >>> from trytond.modules.account.tests.tools import ( ... create_chart, create_fiscalyear, create_tax, create_tax_code, get_accounts) >>> from trytond.modules.account_invoice.tests.tools import ( ... set_fiscalyear_invoice_sequences) >>> from trytond.modules.company.tests.tools import create_company, get_company - >>> from trytond.tests.tools import activate_modules, assertEqual + >>> from trytond.tests.tools import activate_modules, assertEqual, assertTrue >>> today = dt.date.today() @@ -189,6 +191,7 @@ Post invoice:: 'posted' >>> invoice.tax_identifier.code 'BE0897290877' + >>> assertTrue(iso11649.validate(invoice.customer_payment_reference)) >>> bool(invoice.has_report_cache) True >>> invoice.untaxed_amount diff --git a/modules/account_invoice/tests/scenario_invoice_supplier.rst b/modules/account_invoice/tests/scenario_invoice_supplier.rst index 76b198c9be..167c27013b 100644 --- a/modules/account_invoice/tests/scenario_invoice_supplier.rst +++ b/modules/account_invoice/tests/scenario_invoice_supplier.rst @@ -97,6 +97,10 @@ Create invoice:: >>> invoice.payment_term = payment_term >>> invoice.invoice_date = today >>> invoice.reference = 'FAC001' + >>> invoice.supplier_payment_reference_type = 'creditor_reference' + >>> invoice.supplier_payment_reference = 'RF18539007547034' + >>> invoice.supplier_payment_reference + 'RF18 5390 0754 7034' >>> line = InvoiceLine() >>> invoice.lines.append(line) >>> line.product = product diff --git a/modules/account_invoice/tryton.cfg b/modules/account_invoice/tryton.cfg index 51d689cb8b..0162057cff 100644 --- a/modules/account_invoice/tryton.cfg +++ b/modules/account_invoice/tryton.cfg @@ -41,6 +41,7 @@ model: party.PartyPaymentTerm account.Configuration account.ConfigurationDefaultPaymentTerm + account.ConfigurationPaymentReference account.InvoiceSequence # Match pattern migration fallbacks to Fiscalyear values so Period # must be registered before Fiscalyear diff --git a/modules/account_invoice/view/configuration_form.xml b/modules/account_invoice/view/configuration_form.xml index 19df0c4dd0..867c4af630 100644 --- a/modules/account_invoice/view/configuration_form.xml +++ b/modules/account_invoice/view/configuration_form.xml @@ -7,4 +7,9 @@ this repository contains the full copyright notices and license terms. --> + + diff --git a/modules/account_invoice/view/invoice_form.xml b/modules/account_invoice/view/invoice_form.xml index 6be7508e29..9c073ae470 100644 --- a/modules/account_invoice/view/invoice_form.xml +++ b/modules/account_invoice/view/invoice_form.xml @@ -75,6 +75,16 @@ this repository contains the full copyright notices and license terms. -->