diff --git a/account_customer_early_payment_discount/README.rst b/account_customer_early_payment_discount/README.rst new file mode 100644 index 00000000000..bef6c2a6caf --- /dev/null +++ b/account_customer_early_payment_discount/README.rst @@ -0,0 +1,141 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============================== +Customer Early Payment Discount +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d8a9269d26f62b027ca5fe3619de83a127ac4025d50a915b93fc4cf650fad73d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--payment-lightgray.png?logo=github + :target: https://github.com/OCA/account-payment/tree/19.0/account_customer_early_payment_discount + :alt: OCA/account-payment +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-payment-19-0/account-payment-19-0-account_customer_early_payment_discount + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/account-payment&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Companies that grant their customers a discount for paying within a few +days often settle it with a credit note, because a credit note is a +fiscal document the customer can book and, in countries like Mexico, the +only way to report a discount granted after the invoice was issued. + +The native early payment discount of the payment terms does not fit that +flow: it writes the discount off when the payment is registered, counts +calendar days only and takes the same percentage off the whole invoice, +freight and insurance included. + +This module keeps the percentage and the days of the payment terms and +adds: + +- **Settle with a credit note**: the customer pays the discounted + amount, and the discount is granted with a credit note applied to the + invoice, so the payment receipt reports what was really collected. +- **Business days**: the days to pay within are counted on the working + calendar of the company, skipping weekends and holidays. +- **Goods only**: the discount leaves out the services of the invoice, + such as freight or insurance. +- **Approvals**: the credit note is issued in draft, so any approval + flow of the company (for example ``account_move_tier_validation``) can + gate it. It is reconciled with the invoice as soon as it is posted. +- **Follow-up**: the invoices whose customer paid in time and still wait + for the credit note show under the *Early Payment to Apply* filter. + +The last day to pay, the discount, the credit note and the *Apply Early +Payment* button come from ``account_early_payment_base``, shared with +the supplier side (``account_supplier_early_payment_discount``), so both +can be installed together. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +- Open *Invoicing > Configuration > Payment Terms*, tick *Early + Discount* and fill in the percentage and the days as usual. Then tick + *Settle with a Credit Note* and, if needed, *Count Business Days* and + *On Goods Only*. +- For business days, set the working calendar of the company (*Settings + > Companies*) and its holidays as global time off of that calendar. +- The payment must reduce the balance of the invoice for the module to + see it, so the payment methods of the bank journals need an + outstanding receipts account. + +Usage +===== + +A posted customer invoice with such payment terms shows the last day to +pay and the amount of the discount. Once the customer pays at least the +discounted amount within that day, the invoice shows under the *Early +Payment to Apply* filter and the button *Apply Early Payment* appears. +Press it: a draft credit note for the discount is created, with the +eligible lines only and their taxes. Post it (after the approvals of +your company, if any) and it is reconciled with the invoice, which is +then fully paid. + +Whether an invoice is paid in time is decided by +``account.move._early_payment_paid_on_time()``, and the lines the +discount takes off by ``account.move._early_payment_line_eligible()``; +both can be overridden. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Jarsa + +Contributors +------------ + +- Jarsa + + - Alan Ramos + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/account-payment `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_customer_early_payment_discount/__init__.py b/account_customer_early_payment_discount/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/account_customer_early_payment_discount/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_customer_early_payment_discount/__manifest__.py b/account_customer_early_payment_discount/__manifest__.py new file mode 100644 index 00000000000..8f357d35030 --- /dev/null +++ b/account_customer_early_payment_discount/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Customer Early Payment Discount", + "summary": "Early payment discount granted to customers settled by a credit note", + "version": "19.0.1.0.0", + "category": "Accounting", + "website": "https://github.com/OCA/account-payment", + "author": "Jarsa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "development_status": "Beta", + "depends": ["account_early_payment_base"], + "data": [ + "views/account_payment_term_views.xml", + "views/account_move_views.xml", + ], + "installable": True, +} diff --git a/account_customer_early_payment_discount/i18n/account_customer_early_payment_discount.pot b/account_customer_early_payment_discount/i18n/account_customer_early_payment_discount.pot new file mode 100644 index 00000000000..b8a552b2edf --- /dev/null +++ b/account_customer_early_payment_discount/i18n/account_customer_early_payment_discount.pot @@ -0,0 +1,104 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_customer_early_payment_discount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-18 15:09+0000\n" +"PO-Revision-Date: 2026-09-18 15:09+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields.selection,name:account_customer_early_payment_discount.selection__account_move__early_payment_state__applied +msgid "Applied" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__early_discount_business_days +msgid "Count Business Days" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_move__display_name +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__display_name +msgid "Display Name" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_bank_statement_line__early_payment_state +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_move__early_payment_state +msgid "Early Payment State" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model_terms:ir.ui.view,arch_db:account_customer_early_payment_discount.view_account_invoice_filter +msgid "Early Payment to Apply" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_move__id +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__id +msgid "ID" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model,name:account_customer_early_payment_discount.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__early_discount_goods_only +msgid "On Goods Only" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model,name:account_customer_early_payment_discount.model_account_payment_term +msgid "Payment Terms" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__early_discount_credit_note +msgid "Settle with a Credit Note" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_payment_term__early_discount_business_days +msgid "" +"The days to pay within are business days of the working calendar of the " +"company: weekends and holidays do not count." +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_payment_term__early_discount_credit_note +msgid "" +"The discount is granted with a credit note applied to the invoice once the " +"customer pays in time, instead of being written off when the payment is " +"registered." +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_payment_term__early_discount_goods_only +msgid "" +"The discount leaves out the services of the invoice, such as freight or " +"insurance, and takes off the goods only." +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields.selection,name:account_customer_early_payment_discount.selection__account_move__early_payment_state__available +msgid "To Apply" +msgstr "" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_bank_statement_line__early_payment_state +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_move__early_payment_state +msgid "" +"To Apply: the customer paid in time and the credit note is not issued yet. " +"Applied: the credit note is issued." +msgstr "" diff --git a/account_customer_early_payment_discount/i18n/es.po b/account_customer_early_payment_discount/i18n/es.po new file mode 100644 index 00000000000..5563cbedb72 --- /dev/null +++ b/account_customer_early_payment_discount/i18n/es.po @@ -0,0 +1,114 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_customer_early_payment_discount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-18 15:09+0000\n" +"PO-Revision-Date: 2026-09-18 15:09+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields.selection,name:account_customer_early_payment_discount.selection__account_move__early_payment_state__applied +msgid "Applied" +msgstr "Aplicado" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__early_discount_business_days +msgid "Count Business Days" +msgstr "Contar días hábiles" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_move__display_name +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_bank_statement_line__early_payment_state +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_move__early_payment_state +msgid "Early Payment State" +msgstr "Estado del pronto pago" + +#. module: account_customer_early_payment_discount +#: model_terms:ir.ui.view,arch_db:account_customer_early_payment_discount.view_account_invoice_filter +msgid "Early Payment to Apply" +msgstr "Pronto pago por aplicar" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_move__id +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__id +msgid "ID" +msgstr "ID" + +#. module: account_customer_early_payment_discount +#: model:ir.model,name:account_customer_early_payment_discount.model_account_move +msgid "Journal Entry" +msgstr "Asiento contable" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__early_discount_goods_only +msgid "On Goods Only" +msgstr "Solo sobre bienes" + +#. module: account_customer_early_payment_discount +#: model:ir.model,name:account_customer_early_payment_discount.model_account_payment_term +msgid "Payment Terms" +msgstr "Términos de pago" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,field_description:account_customer_early_payment_discount.field_account_payment_term__early_discount_credit_note +msgid "Settle with a Credit Note" +msgstr "Liquidar con nota de crédito" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_payment_term__early_discount_business_days +msgid "" +"The days to pay within are business days of the working calendar of the " +"company: weekends and holidays do not count." +msgstr "" +"Los días del plazo son días hábiles del calendario laboral de la compañía: " +"los fines de semana y los días festivos no cuentan." + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_payment_term__early_discount_credit_note +msgid "" +"The discount is granted with a credit note applied to the invoice once the " +"customer pays in time, instead of being written off when the payment is " +"registered." +msgstr "" +"El descuento se otorga con una nota de crédito aplicada a la factura cuando " +"el cliente paga a tiempo, en lugar de cancelarse como diferencia al " +"registrar el pago." + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_payment_term__early_discount_goods_only +msgid "" +"The discount leaves out the services of the invoice, such as freight or " +"insurance, and takes off the goods only." +msgstr "" +"El descuento excluye los servicios de la factura, como fletes o seguros, y " +"se aplica únicamente a los bienes." + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields.selection,name:account_customer_early_payment_discount.selection__account_move__early_payment_state__available +msgid "To Apply" +msgstr "Por aplicar" + +#. module: account_customer_early_payment_discount +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_bank_statement_line__early_payment_state +#: model:ir.model.fields,help:account_customer_early_payment_discount.field_account_move__early_payment_state +msgid "" +"To Apply: the customer paid in time and the credit note is not issued yet. " +"Applied: the credit note is issued." +msgstr "" +"Por aplicar: el cliente pagó a tiempo y la nota de crédito aún no se emite. " +"Aplicado: la nota de crédito ya fue emitida." diff --git a/account_customer_early_payment_discount/models/__init__.py b/account_customer_early_payment_discount/models/__init__.py new file mode 100644 index 00000000000..4fc130fb551 --- /dev/null +++ b/account_customer_early_payment_discount/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_move +from . import account_payment_term diff --git a/account_customer_early_payment_discount/models/account_move.py b/account_customer_early_payment_discount/models/account_move.py new file mode 100644 index 00000000000..4dbbeead622 --- /dev/null +++ b/account_customer_early_payment_discount/models/account_move.py @@ -0,0 +1,143 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import timedelta + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + early_payment_state = fields.Selection( + [("available", "To Apply"), ("applied", "Applied")], + compute="_compute_early_payment_state", + store=True, + help="To Apply: the customer paid in time and the credit note is not " + "issued yet. Applied: the credit note is issued.", + ) + + @api.depends( + "invoice_payment_term_id.early_discount", + "invoice_payment_term_id.early_discount_credit_note", + "invoice_payment_term_id.early_discount_business_days", + "invoice_payment_term_id.early_discount_goods_only", + "invoice_payment_term_id.discount_percentage", + "invoice_payment_term_id.discount_days", + "invoice_line_ids.price_total", + "invoice_line_ids.product_id.type", + ) + def _compute_early_payment(self): + return super()._compute_early_payment() + + @api.depends( + "state", + "early_payment_date", + "early_payment_amount", + "early_payment_credit_note_id.state", + "amount_residual", + ) + def _compute_early_payment_state(self): + for move in self: + credit_note = move.early_payment_credit_note_id + if credit_note and credit_note.state != "cancel": + move.early_payment_state = "applied" + elif ( + move.state == "posted" + and move.early_payment_date + and move.early_payment_amount > 0 + and move._early_payment_paid_on_time() + ): + move.early_payment_state = "available" + else: + move.early_payment_state = False + + @api.depends("early_payment_state") + def _compute_early_payment_can_apply(self): + return super()._compute_early_payment_can_apply() + + def _get_early_payment_values(self): + values = super()._get_early_payment_values() + term = self.invoice_payment_term_id + if not ( + self.move_type == "out_invoice" + and self.invoice_date + and term.early_discount + and term.early_discount_credit_note + ): + return values + base = sum(self._early_payment_eligible_lines().mapped("price_total")) + return { + "date": ( + self.company_id._add_business_days( + self.invoice_date, term.discount_days + ) + if term.early_discount_business_days + else self.invoice_date + timedelta(days=term.discount_days) + ), + "amount": self.currency_id.round(base * term.discount_percentage / 100.0), + } + + def _early_payment_can_apply(self): + if self.move_type == "out_invoice": + return self.early_payment_state == "available" + return super()._early_payment_can_apply() + + def _early_payment_line_eligible(self, line): + """Whether the discount takes off this line: the goods only when the + payment terms say so, every product line otherwise.""" + return bool( + line.display_type == "product" + and line.product_id + and ( + not self.invoice_payment_term_id.early_discount_goods_only + or line.product_id.type == "consu" + ) + ) + + def _early_payment_eligible_lines(self): + self.ensure_one() + return self.invoice_line_ids.filtered(self._early_payment_line_eligible) + + def _early_payment_paid_on_time(self): + """Whether the customer paid the discounted amount within the early + payment date. Credit notes do not count as payments.""" + self.ensure_one() + partials, _exchange = self._get_reconciled_invoices_partials() + payments = [ + (amount, line) + for _partial, amount, line in partials + if line.move_id.move_type not in ("out_refund", "in_refund") + ] + if not payments: + return False + paid = sum(amount for amount, _line in payments) + due = self.amount_total - self.early_payment_amount + return ( + self.currency_id.compare_amounts(paid, due) >= 0 + and max(line.date for _amount, line in payments) <= self.early_payment_date + ) + + def _early_payment_adjust_credit_note(self, credit_note): + super()._early_payment_adjust_credit_note(credit_note) + if self.move_type != "out_invoice": + return + # Left in draft so the approvals of the company can gate it. + percentage = self.invoice_payment_term_id.discount_percentage + for line in credit_note.invoice_line_ids: + if line.display_type != "product": + continue + if self._early_payment_line_eligible(line): + line.price_unit = line.price_unit * percentage / 100.0 + else: + line.unlink() + + def action_apply_early_payment(self): + credit_note = super().action_apply_early_payment() + if self.move_type != "out_invoice": + return credit_note + return { + "type": "ir.actions.act_window", + "res_model": "account.move", + "res_id": credit_note.id, + "view_mode": "form", + } diff --git a/account_customer_early_payment_discount/models/account_payment_term.py b/account_customer_early_payment_discount/models/account_payment_term.py new file mode 100644 index 00000000000..3ce6da76b89 --- /dev/null +++ b/account_customer_early_payment_discount/models/account_payment_term.py @@ -0,0 +1,33 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class AccountPaymentTerm(models.Model): + _inherit = "account.payment.term" + + early_discount_credit_note = fields.Boolean( + string="Settle with a Credit Note", + help="The discount is granted with a credit note applied to the " + "invoice once the customer pays in time, instead of being written " + "off when the payment is registered.", + ) + early_discount_business_days = fields.Boolean( + string="Count Business Days", + help="The days to pay within are business days of the working " + "calendar of the company: weekends and holidays do not count.", + ) + early_discount_goods_only = fields.Boolean( + string="On Goods Only", + help="The discount leaves out the services of the invoice, such as " + "freight or insurance, and takes off the goods only.", + ) + + def _compute_terms(self, *args, **kwargs): + # The native discount is written off in the payment; here the credit + # note settles it, so the payment must be for the full amount. + res = super()._compute_terms(*args, **kwargs) + if self.early_discount_credit_note: + res.update(discount_percentage=0.0, discount_date=False, discount_balance=0) + res.pop("discount_amount_currency", None) + return res diff --git a/account_customer_early_payment_discount/pyproject.toml b/account_customer_early_payment_discount/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/account_customer_early_payment_discount/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_customer_early_payment_discount/readme/CONFIGURE.md b/account_customer_early_payment_discount/readme/CONFIGURE.md new file mode 100644 index 00000000000..b630846dce2 --- /dev/null +++ b/account_customer_early_payment_discount/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +- Open *Invoicing > Configuration > Payment Terms*, tick *Early Discount* and + fill in the percentage and the days as usual. Then tick *Settle with a Credit + Note* and, if needed, *Count Business Days* and *On Goods Only*. +- For business days, set the working calendar of the company (*Settings > + Companies*) and its holidays as global time off of that calendar. +- The payment must reduce the balance of the invoice for the module to see it, + so the payment methods of the bank journals need an outstanding receipts + account. diff --git a/account_customer_early_payment_discount/readme/CONTRIBUTORS.md b/account_customer_early_payment_discount/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..5501ed298f7 --- /dev/null +++ b/account_customer_early_payment_discount/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Jarsa + - Alan Ramos \ diff --git a/account_customer_early_payment_discount/readme/DESCRIPTION.md b/account_customer_early_payment_discount/readme/DESCRIPTION.md new file mode 100644 index 00000000000..829d1048a57 --- /dev/null +++ b/account_customer_early_payment_discount/readme/DESCRIPTION.md @@ -0,0 +1,29 @@ +Companies that grant their customers a discount for paying within a few days +often settle it with a credit note, because a credit note is a fiscal document +the customer can book and, in countries like Mexico, the only way to report a +discount granted after the invoice was issued. + +The native early payment discount of the payment terms does not fit that flow: +it writes the discount off when the payment is registered, counts calendar +days only and takes the same percentage off the whole invoice, freight and +insurance included. + +This module keeps the percentage and the days of the payment terms and adds: + +- **Settle with a credit note**: the customer pays the discounted amount, and + the discount is granted with a credit note applied to the invoice, so the + payment receipt reports what was really collected. +- **Business days**: the days to pay within are counted on the working + calendar of the company, skipping weekends and holidays. +- **Goods only**: the discount leaves out the services of the invoice, such as + freight or insurance. +- **Approvals**: the credit note is issued in draft, so any approval flow of + the company (for example `account_move_tier_validation`) can gate it. It is + reconciled with the invoice as soon as it is posted. +- **Follow-up**: the invoices whose customer paid in time and still wait for + the credit note show under the *Early Payment to Apply* filter. + +The last day to pay, the discount, the credit note and the *Apply Early +Payment* button come from `account_early_payment_base`, shared with the +supplier side (`account_supplier_early_payment_discount`), so both can be +installed together. diff --git a/account_customer_early_payment_discount/readme/USAGE.md b/account_customer_early_payment_discount/readme/USAGE.md new file mode 100644 index 00000000000..4382b8e5aa9 --- /dev/null +++ b/account_customer_early_payment_discount/readme/USAGE.md @@ -0,0 +1,11 @@ +A posted customer invoice with such payment terms shows the last day to pay +and the amount of the discount. Once the customer pays at least the discounted +amount within that day, the invoice shows under the *Early Payment to Apply* +filter and the button *Apply Early Payment* appears. Press it: a draft credit +note for the discount is created, with the eligible lines only and their +taxes. Post it (after the approvals of your company, if any) and it is +reconciled with the invoice, which is then fully paid. + +Whether an invoice is paid in time is decided by +`account.move._early_payment_paid_on_time()`, and the lines the discount takes +off by `account.move._early_payment_line_eligible()`; both can be overridden. diff --git a/account_customer_early_payment_discount/static/description/index.html b/account_customer_early_payment_discount/static/description/index.html new file mode 100644 index 00000000000..5055e7dc2d0 --- /dev/null +++ b/account_customer_early_payment_discount/static/description/index.html @@ -0,0 +1,490 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Customer Early Payment Discount

+ +

Beta License: AGPL-3 OCA/account-payment Translate me on Weblate Try me on Runboat

+

Companies that grant their customers a discount for paying within a few +days often settle it with a credit note, because a credit note is a +fiscal document the customer can book and, in countries like Mexico, the +only way to report a discount granted after the invoice was issued.

+

The native early payment discount of the payment terms does not fit that +flow: it writes the discount off when the payment is registered, counts +calendar days only and takes the same percentage off the whole invoice, +freight and insurance included.

+

This module keeps the percentage and the days of the payment terms and +adds:

+
    +
  • Settle with a credit note: the customer pays the discounted +amount, and the discount is granted with a credit note applied to the +invoice, so the payment receipt reports what was really collected.
  • +
  • Business days: the days to pay within are counted on the working +calendar of the company, skipping weekends and holidays.
  • +
  • Goods only: the discount leaves out the services of the invoice, +such as freight or insurance.
  • +
  • Approvals: the credit note is issued in draft, so any approval +flow of the company (for example account_move_tier_validation) can +gate it. It is reconciled with the invoice as soon as it is posted.
  • +
  • Follow-up: the invoices whose customer paid in time and still wait +for the credit note show under the Early Payment to Apply filter.
  • +
+

The last day to pay, the discount, the credit note and the Apply Early +Payment button come from account_early_payment_base, shared with +the supplier side (account_supplier_early_payment_discount), so both +can be installed together.

+

Table of contents

+ +
+

Configuration

+
    +
  • Open Invoicing > Configuration > Payment Terms, tick Early +Discount and fill in the percentage and the days as usual. Then tick +Settle with a Credit Note and, if needed, Count Business Days and +On Goods Only.
  • +
  • For business days, set the working calendar of the company (Settings +> Companies) and its holidays as global time off of that calendar.
  • +
  • The payment must reduce the balance of the invoice for the module to +see it, so the payment methods of the bank journals need an +outstanding receipts account.
  • +
+
+
+

Usage

+

A posted customer invoice with such payment terms shows the last day to +pay and the amount of the discount. Once the customer pays at least the +discounted amount within that day, the invoice shows under the Early +Payment to Apply filter and the button Apply Early Payment appears. +Press it: a draft credit note for the discount is created, with the +eligible lines only and their taxes. Post it (after the approvals of +your company, if any) and it is reconciled with the invoice, which is +then fully paid.

+

Whether an invoice is paid in time is decided by +account.move._early_payment_paid_on_time(), and the lines the +discount takes off by account.move._early_payment_line_eligible(); +both can be overridden.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Jarsa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/account-payment project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/account_customer_early_payment_discount/tests/__init__.py b/account_customer_early_payment_discount/tests/__init__.py new file mode 100644 index 00000000000..eb9b5437bcb --- /dev/null +++ b/account_customer_early_payment_discount/tests/__init__.py @@ -0,0 +1 @@ +from . import test_customer_early_payment diff --git a/account_customer_early_payment_discount/tests/test_customer_early_payment.py b/account_customer_early_payment_discount/tests/test_customer_early_payment.py new file mode 100644 index 00000000000..e8e51dbdd59 --- /dev/null +++ b/account_customer_early_payment_discount/tests/test_customer_early_payment.py @@ -0,0 +1,199 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import date + +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestCustomerEarlyPayment(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + # Monday to Friday, with the 16th of September off. + cls.calendar = cls.env["resource.calendar"].create( + {"name": "Business days", "company_id": cls.company.id} + ) + cls.env["resource.calendar.leaves"].create( + { + "name": "Holiday", + "calendar_id": cls.calendar.id, + "date_from": "2026-09-16 00:00:00", + "date_to": "2026-09-16 23:59:59", + } + ) + cls.company.resource_calendar_id = cls.calendar + cls.term = cls.env["account.payment.term"].create( + { + "name": "3% in 8 business days", + "early_discount": True, + "discount_percentage": 3.0, + "discount_days": 8, + "early_discount_credit_note": True, + "early_discount_business_days": True, + "early_discount_goods_only": True, + "line_ids": [ + Command.create( + {"value": "percent", "value_amount": 100, "nb_days": 30} + ) + ], + } + ) + cls.tax = cls.env["account.tax"].create( + {"name": "VAT 16%", "amount": 16.0, "type_tax_use": "sale"} + ) + cls.goods = cls.env["product.product"].create( + {"name": "Helmet", "type": "consu", "taxes_id": [Command.set(cls.tax.ids)]} + ) + cls.service = cls.env["product.product"].create( + { + "name": "Freight", + "type": "service", + "taxes_id": [Command.set(cls.tax.ids)], + } + ) + cls.customer = cls.env["res.partner"].create({"name": "Early Payer"}) + # The payment must reduce the balance of the invoice right away. + cls.journal = cls.env["account.journal"].search( + [("type", "=", "bank"), ("company_id", "=", cls.company.id)], limit=1 + ) + outstanding = cls.env["account.account"].create( + { + "name": "Outstanding Receipts", + "code": "EPDOUT", + "account_type": "asset_current", + "reconcile": True, + } + ) + cls.journal.inbound_payment_method_line_ids.payment_account_id = outstanding + + def _invoice(self, term=None, invoice_date="2026-09-11"): + invoice = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.customer.id, + "invoice_date": invoice_date, + "invoice_payment_term_id": (term or self.term).id, + "invoice_line_ids": [ + Command.create( + { + "product_id": self.goods.id, + "quantity": 10, + "price_unit": 100.0, + } + ), + Command.create( + { + "product_id": self.service.id, + "quantity": 1, + "price_unit": 200.0, + } + ), + ], + } + ) + invoice.action_post() + return invoice + + def _pay(self, invoice, amount, payment_date): + wizard = ( + self.env["account.payment.register"] + .with_context(active_model="account.move", active_ids=invoice.ids) + .create({"amount": amount, "payment_date": payment_date}) + ) + return wizard._create_payments() + + def test_date_and_amount(self): + invoice = self._invoice() + # Friday the 11th + 8 business days, skipping the holiday of the 16th. + self.assertEqual(invoice.early_payment_date, date(2026, 9, 24)) + # 3% of the goods with their tax (1,160), the freight left out. + self.assertEqual(invoice.early_payment_amount, 34.80) + self.assertEqual(invoice.amount_total, 1392.0) + self.assertFalse(invoice.early_payment_state) + # The native write-off is off: the customer owes the full amount. + receivable = invoice.line_ids.filtered( + lambda line: line.account_type == "asset_receivable" + ) + self.assertFalse(receivable.discount_date) + wizard = ( + self.env["account.payment.register"] + .with_context(active_model="account.move", active_ids=invoice.ids) + .create({}) + ) + self.assertFalse(wizard.early_payment_discount_mode) + self.assertEqual(wizard.amount, 1392.0) + + def test_calendar_days_whole_invoice(self): + self.term.write( + {"early_discount_business_days": False, "early_discount_goods_only": False} + ) + invoice = self._invoice() + self.assertEqual(invoice.early_payment_date, date(2026, 9, 19)) + self.assertEqual(invoice.early_payment_amount, 41.76) + + def test_apply(self): + invoice = self._invoice() + self._pay(invoice, 1357.20, "2026-09-24") + self.assertEqual(invoice.early_payment_state, "available") + action = invoice.action_apply_early_payment() + credit_note = self.env["account.move"].browse(action["res_id"]) + self.assertEqual(invoice.early_payment_credit_note_id, credit_note) + self.assertEqual(invoice.early_payment_state, "applied") + self.assertEqual(credit_note.state, "draft") + self.assertTrue(credit_note.is_early_payment_refund) + self.assertEqual(credit_note.reversed_entry_id, invoice) + self.assertEqual(credit_note.invoice_line_ids.product_id, self.goods) + self.assertEqual(credit_note.amount_total, 34.80) + with self.assertRaises(UserError): + invoice.action_apply_early_payment() + credit_note.action_post() + self.assertEqual(invoice.amount_residual, 0.0) + self.assertIn(invoice.payment_state, ("paid", "in_payment")) + self.assertTrue(credit_note.line_ids.filtered("reconciled")) + + def test_cancelled_credit_note_frees_the_discount(self): + invoice = self._invoice() + self._pay(invoice, 1357.20, "2026-09-24") + invoice.action_apply_early_payment() + invoice.early_payment_credit_note_id.button_cancel() + self.assertEqual(invoice.early_payment_state, "available") + + def test_paid_late(self): + invoice = self._invoice() + self._pay(invoice, 1357.20, "2026-09-25") + self.assertFalse(invoice.early_payment_state) + with self.assertRaises(UserError): + invoice.action_apply_early_payment() + + def test_paid_too_little(self): + invoice = self._invoice() + self._pay(invoice, 1000.0, "2026-09-14") + self.assertFalse(invoice.early_payment_state) + self._pay(invoice, 357.20, "2026-09-24") + self.assertEqual(invoice.early_payment_state, "available") + + def test_other_terms_untouched(self): + term = self.env["account.payment.term"].create( + { + "name": "Native 2/7", + "early_discount": True, + "discount_percentage": 2.0, + "discount_days": 7, + "line_ids": [ + Command.create( + {"value": "percent", "value_amount": 100, "nb_days": 30} + ) + ], + } + ) + invoice = self._invoice(term=term) + self.assertFalse(invoice.early_payment_date) + receivable = invoice.line_ids.filtered( + lambda line: line.account_type == "asset_receivable" + ) + self.assertEqual(receivable.discount_date, date(2026, 9, 18)) diff --git a/account_customer_early_payment_discount/views/account_move_views.xml b/account_customer_early_payment_discount/views/account_move_views.xml new file mode 100644 index 00000000000..11e5a6a568b --- /dev/null +++ b/account_customer_early_payment_discount/views/account_move_views.xml @@ -0,0 +1,17 @@ + + + + account.move.search.inherit.customer.early.payment + account.move + + + + + + + + diff --git a/account_customer_early_payment_discount/views/account_payment_term_views.xml b/account_customer_early_payment_discount/views/account_payment_term_views.xml new file mode 100644 index 00000000000..0edf4df06ac --- /dev/null +++ b/account_customer_early_payment_discount/views/account_payment_term_views.xml @@ -0,0 +1,29 @@ + + + + account.payment.term.form.inherit.customer.early.payment + account.payment.term + + + +
+ +
+
+ +
+
+ +
+
+
+
+
diff --git a/account_early_payment_base/README.rst b/account_early_payment_base/README.rst new file mode 100644 index 00000000000..a1750356ce4 --- /dev/null +++ b/account_early_payment_base/README.rst @@ -0,0 +1,98 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========================== +Early Payment Discount Base +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1137e57a5c49641188846285b624d5ae66c5fcf7b7bba1c944ec056f6846381e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--payment-lightgray.png?logo=github + :target: https://github.com/OCA/account-payment/tree/19.0/account_early_payment_base + :alt: OCA/account-payment +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-payment-19-0/account-payment-19-0-account_early_payment_base + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/account-payment&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Technical module shared by the early payment discounts settled by a +credit note: ``account_supplier_early_payment_discount`` (vendor bills) +and ``account_customer_early_payment_discount`` (customer invoices). It +holds, on ``account.move``, the last day to pay, the amount of the +discount, the credit note that settles it and the *Apply Early Payment* +button, plus the business day helpers of ``res.company``, so both +modules can be installed together. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Install ``account_supplier_early_payment_discount`` or +``account_customer_early_payment_discount``; this module does nothing on +its own. Extending modules override ``_get_early_payment_values``, +``_early_payment_can_apply`` and ``_early_payment_adjust_credit_note`` +for the move types they handle. A credit note flagged +``is_early_payment_refund`` is reconciled with its invoice when posted. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Jarsa + +Contributors +------------ + +- Jarsa + + - Alan Ramos + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/account-payment `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_early_payment_base/__init__.py b/account_early_payment_base/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/account_early_payment_base/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_early_payment_base/__manifest__.py b/account_early_payment_base/__manifest__.py new file mode 100644 index 00000000000..cb6c6733711 --- /dev/null +++ b/account_early_payment_base/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Early Payment Discount Base", + "summary": "Common ground of the early payment discounts settled by a credit note", + "version": "19.0.1.0.0", + "category": "Accounting", + "website": "https://github.com/OCA/account-payment", + "author": "Jarsa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "development_status": "Beta", + "depends": ["account", "resource"], + "data": [ + "views/account_move_views.xml", + ], + "installable": True, +} diff --git a/account_early_payment_base/i18n/account_early_payment_base.pot b/account_early_payment_base/i18n/account_early_payment_base.pot new file mode 100644 index 00000000000..fa4d5327509 --- /dev/null +++ b/account_early_payment_base/i18n/account_early_payment_base.pot @@ -0,0 +1,122 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_early_payment_base +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-18 15:09+0000\n" +"PO-Revision-Date: 2026-09-18 15:09+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_early_payment_base +#: model_terms:ir.ui.view,arch_db:account_early_payment_base.view_move_form +msgid "" +"A credit note for the early payment discount will be created and applied to " +"this invoice. Continue?" +msgstr "" + +#. module: account_early_payment_base +#: model_terms:ir.ui.view,arch_db:account_early_payment_base.view_move_form +msgid "Apply Early Payment" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model,name:account_early_payment_base.model_res_company +msgid "Companies" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_credit_note_id +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_credit_note_id +msgid "Credit note that settles the early payment discount of this invoice." +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__display_name +#: model:ir.model.fields,field_description:account_early_payment_base.field_res_company__display_name +msgid "Display Name" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_amount +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_amount +msgid "Early Payment Amount" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_can_apply +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_can_apply +msgid "Early Payment Can Apply" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_credit_note_id +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_credit_note_id +msgid "Early Payment Credit Note" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_date +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_date +msgid "Early Payment Date" +msgstr "" + +#. module: account_early_payment_base +#. odoo-python +#: code:addons/account_early_payment_base/models/account_move.py:0 +msgid "Early payment discount of %s" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__id +#: model:ir.model.fields,field_description:account_early_payment_base.field_res_company__id +msgid "ID" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__is_early_payment_refund +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__is_early_payment_refund +msgid "Is Early Payment Refund" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model,name:account_early_payment_base.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_date +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_date +msgid "Last day to pay to earn the early payment discount." +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_can_apply +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_can_apply +msgid "The discount is earned and its credit note is not issued yet." +msgstr "" + +#. module: account_early_payment_base +#. odoo-python +#: code:addons/account_early_payment_base/models/account_move.py:0 +msgid "The early payment discount of %s cannot be applied." +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__is_early_payment_refund +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__is_early_payment_refund +msgid "This credit note settles an early payment discount." +msgstr "" + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_amount +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_amount +msgid "What the early payment discount takes off this invoice." +msgstr "" diff --git a/account_early_payment_base/i18n/es.po b/account_early_payment_base/i18n/es.po new file mode 100644 index 00000000000..347fd243388 --- /dev/null +++ b/account_early_payment_base/i18n/es.po @@ -0,0 +1,126 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_early_payment_base +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-18 15:09+0000\n" +"PO-Revision-Date: 2026-09-18 15:09+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: account_early_payment_base +#: model_terms:ir.ui.view,arch_db:account_early_payment_base.view_move_form +msgid "" +"A credit note for the early payment discount will be created and applied to " +"this invoice. Continue?" +msgstr "" +"Se creará una nota de crédito por el descuento de pronto pago y se aplicará " +"a esta factura. ¿Continuar?" + +#. module: account_early_payment_base +#: model_terms:ir.ui.view,arch_db:account_early_payment_base.view_move_form +msgid "Apply Early Payment" +msgstr "Aplicar pronto pago" + +#. module: account_early_payment_base +#: model:ir.model,name:account_early_payment_base.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_credit_note_id +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_credit_note_id +msgid "Credit note that settles the early payment discount of this invoice." +msgstr "" +"Nota de crédito que liquida el descuento por pronto pago de esta factura." + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__display_name +#: model:ir.model.fields,field_description:account_early_payment_base.field_res_company__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_amount +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_amount +msgid "Early Payment Amount" +msgstr "Importe del pronto pago" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_can_apply +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_can_apply +msgid "Early Payment Can Apply" +msgstr "Pronto pago aplicable" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_credit_note_id +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_credit_note_id +msgid "Early Payment Credit Note" +msgstr "Nota de crédito de pronto pago" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__early_payment_date +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__early_payment_date +msgid "Early Payment Date" +msgstr "Fecha límite de pronto pago" + +#. module: account_early_payment_base +#. odoo-python +#: code:addons/account_early_payment_base/models/account_move.py:0 +msgid "Early payment discount of %s" +msgstr "Descuento por pronto pago de %s" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__id +#: model:ir.model.fields,field_description:account_early_payment_base.field_res_company__id +msgid "ID" +msgstr "ID" + +#. module: account_early_payment_base +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_bank_statement_line__is_early_payment_refund +#: model:ir.model.fields,field_description:account_early_payment_base.field_account_move__is_early_payment_refund +msgid "Is Early Payment Refund" +msgstr "Es nota de crédito de pronto pago" + +#. module: account_early_payment_base +#: model:ir.model,name:account_early_payment_base.model_account_move +msgid "Journal Entry" +msgstr "Asiento contable" + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_date +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_date +msgid "Last day to pay to earn the early payment discount." +msgstr "Último día para pagar y ganar el descuento por pronto pago." + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_can_apply +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_can_apply +msgid "The discount is earned and its credit note is not issued yet." +msgstr "El descuento ya se ganó y su nota de crédito aún no se emite." + +#. module: account_early_payment_base +#. odoo-python +#: code:addons/account_early_payment_base/models/account_move.py:0 +msgid "The early payment discount of %s cannot be applied." +msgstr "No se puede aplicar el descuento por pronto pago de %s." + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__is_early_payment_refund +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__is_early_payment_refund +msgid "This credit note settles an early payment discount." +msgstr "Esta nota de crédito liquida un descuento por pronto pago." + +#. module: account_early_payment_base +#: model:ir.model.fields,help:account_early_payment_base.field_account_bank_statement_line__early_payment_amount +#: model:ir.model.fields,help:account_early_payment_base.field_account_move__early_payment_amount +msgid "What the early payment discount takes off this invoice." +msgstr "Lo que el descuento por pronto pago resta a esta factura." diff --git a/account_early_payment_base/models/__init__.py b/account_early_payment_base/models/__init__.py new file mode 100644 index 00000000000..3cd5ec9bff4 --- /dev/null +++ b/account_early_payment_base/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_move +from . import res_company diff --git a/account_early_payment_base/models/account_move.py b/account_early_payment_base/models/account_move.py new file mode 100644 index 00000000000..a1da578d923 --- /dev/null +++ b/account_early_payment_base/models/account_move.py @@ -0,0 +1,108 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + _inherit = "account.move" + + early_payment_date = fields.Date( + compute="_compute_early_payment", + store=True, + help="Last day to pay to earn the early payment discount.", + ) + early_payment_amount = fields.Monetary( + compute="_compute_early_payment", + store=True, + help="What the early payment discount takes off this invoice.", + ) + early_payment_can_apply = fields.Boolean( + compute="_compute_early_payment_can_apply", + help="The discount is earned and its credit note is not issued yet.", + ) + early_payment_credit_note_id = fields.Many2one( + "account.move", + readonly=True, + copy=False, + help="Credit note that settles the early payment discount of this invoice.", + ) + is_early_payment_refund = fields.Boolean( + readonly=True, + copy=False, + help="This credit note settles an early payment discount.", + ) + + @api.depends("move_type", "invoice_date", "company_id") + def _compute_early_payment(self): + """Extending modules add their dependencies and compute the values in + `_get_early_payment_values` for the move types they handle.""" + for move in self: + values = move._get_early_payment_values() + move.early_payment_date = values.get("date", False) + move.early_payment_amount = values.get("amount", 0.0) + + @api.depends("early_payment_amount", "early_payment_credit_note_id.state", "state") + def _compute_early_payment_can_apply(self): + for move in self: + move.early_payment_can_apply = move._early_payment_can_apply() + + def _get_early_payment_values(self): + """Date and amount of the discount of this move, empty when none.""" + self.ensure_one() + return {} + + def _early_payment_can_apply(self): + """Whether the discount is earned and still to settle.""" + self.ensure_one() + credit_note = self.early_payment_credit_note_id + return bool( + self.state == "posted" + and self.early_payment_amount > 0 + and (not credit_note or credit_note.state == "cancel") + ) + + def _early_payment_credit_note_values(self): + """Defaults of the credit note that settles the discount.""" + self.ensure_one() + today = fields.Date.context_today(self) + return { + "invoice_date": today, + "date": today, + "ref": self.env._("Early payment discount of %s", self.name), + "invoice_payment_term_id": False, + "is_early_payment_refund": True, + } + + def _early_payment_adjust_credit_note(self, credit_note): + """Turn the full reversal into the discount: extending modules scale + or drop its lines and post it when their flow says so.""" + self.ensure_one() + + def _post(self, soft=True): + posted = super()._post(soft=soft) + for move in posted.filtered("is_early_payment_refund"): + invoice = move.reversed_entry_id + (invoice.line_ids + move.line_ids).filtered( + lambda line: line.account_type + in ("asset_receivable", "liability_payable") + and not line.reconciled + ).reconcile() + return posted + + def action_apply_early_payment(self): + """Settle the early payment discount with a credit note. + + :return: the credit note, adjusted (and posted) by the extending module. + """ + self.ensure_one() + if not self.early_payment_can_apply: + raise UserError( + self.env._( + "The early payment discount of %s cannot be applied.", self.name + ) + ) + credit_note = self._reverse_moves([self._early_payment_credit_note_values()]) + self._early_payment_adjust_credit_note(credit_note) + self.early_payment_credit_note_id = credit_note + return credit_note diff --git a/account_early_payment_base/models/res_company.py b/account_early_payment_base/models/res_company.py new file mode 100644 index 00000000000..289fc8d700c --- /dev/null +++ b/account_early_payment_base/models/res_company.py @@ -0,0 +1,38 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import datetime, time, timedelta + +import pytz + +from odoo import models + + +class ResCompany(models.Model): + _inherit = "res.company" + + def _early_payment_calendar(self): + """Working hours of the company, the ones that tell a business day.""" + self.ensure_one() + return self.resource_calendar_id or self.env["resource.calendar"].search( + [("company_id", "in", [self.id, False])], limit=1 + ) + + def _add_business_days(self, start, days): + """Date `days` business days after `start`. + + A business day is a day the working calendar of the company has hours + on, so its holidays are skipped along with the weekends. `start` + itself never counts as one of the days. + """ + self.ensure_one() + calendar = self._early_payment_calendar() + if not calendar: + return start + timedelta(days=days) + begin = pytz.timezone(calendar.tz).localize( + datetime.combine(start + timedelta(days=1), time.min) + ) + return calendar.plan_days(days, begin, compute_leaves=True).date() + + def _is_business_day(self, date): + self.ensure_one() + return self._add_business_days(date - timedelta(days=1), 1) == date diff --git a/account_early_payment_base/pyproject.toml b/account_early_payment_base/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/account_early_payment_base/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_early_payment_base/readme/CONTRIBUTORS.md b/account_early_payment_base/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..5501ed298f7 --- /dev/null +++ b/account_early_payment_base/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Jarsa + - Alan Ramos \ diff --git a/account_early_payment_base/readme/DESCRIPTION.md b/account_early_payment_base/readme/DESCRIPTION.md new file mode 100644 index 00000000000..46f77950975 --- /dev/null +++ b/account_early_payment_base/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +Technical module shared by the early payment discounts settled by a credit +note: `account_supplier_early_payment_discount` (vendor bills) and +`account_customer_early_payment_discount` (customer invoices). It holds, on +`account.move`, the last day to pay, the amount of the discount, the credit +note that settles it and the *Apply Early Payment* button, plus the business +day helpers of `res.company`, so both modules can be installed together. diff --git a/account_early_payment_base/readme/USAGE.md b/account_early_payment_base/readme/USAGE.md new file mode 100644 index 00000000000..2753ad79457 --- /dev/null +++ b/account_early_payment_base/readme/USAGE.md @@ -0,0 +1,6 @@ +Install `account_supplier_early_payment_discount` or +`account_customer_early_payment_discount`; this module does nothing on its +own. Extending modules override `_get_early_payment_values`, +`_early_payment_can_apply` and `_early_payment_adjust_credit_note` for the +move types they handle. A credit note flagged `is_early_payment_refund` is +reconciled with its invoice when posted. diff --git a/account_early_payment_base/static/description/index.html b/account_early_payment_base/static/description/index.html new file mode 100644 index 00000000000..bf84bb0a5ca --- /dev/null +++ b/account_early_payment_base/static/description/index.html @@ -0,0 +1,448 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Early Payment Discount Base

+ +

Beta License: AGPL-3 OCA/account-payment Translate me on Weblate Try me on Runboat

+

Technical module shared by the early payment discounts settled by a +credit note: account_supplier_early_payment_discount (vendor bills) +and account_customer_early_payment_discount (customer invoices). It +holds, on account.move, the last day to pay, the amount of the +discount, the credit note that settles it and the Apply Early Payment +button, plus the business day helpers of res.company, so both +modules can be installed together.

+

Table of contents

+ +
+

Usage

+

Install account_supplier_early_payment_discount or +account_customer_early_payment_discount; this module does nothing on +its own. Extending modules override _get_early_payment_values, +_early_payment_can_apply and _early_payment_adjust_credit_note +for the move types they handle. A credit note flagged +is_early_payment_refund is reconciled with its invoice when posted.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Jarsa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/account-payment project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/account_early_payment_base/tests/__init__.py b/account_early_payment_base/tests/__init__.py new file mode 100644 index 00000000000..1ace6600bde --- /dev/null +++ b/account_early_payment_base/tests/__init__.py @@ -0,0 +1 @@ +from . import test_early_payment_base diff --git a/account_early_payment_base/tests/test_early_payment_base.py b/account_early_payment_base/tests/test_early_payment_base.py new file mode 100644 index 00000000000..41b07c427d4 --- /dev/null +++ b/account_early_payment_base/tests/test_early_payment_base.py @@ -0,0 +1,66 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import date + +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestEarlyPaymentBase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + cls.company.resource_calendar_id = cls.env["resource.calendar"].create( + {"name": "Monday to Friday", "company_id": cls.company.id} + ) + cls.env["resource.calendar.leaves"].create( + { + "name": "Holiday", + "calendar_id": cls.company.resource_calendar_id.id, + "date_from": "2026-09-16 00:00:00", + "date_to": "2026-09-16 23:59:59", + } + ) + + def test_business_days_skip_weekends_and_holidays(self): + # Friday the 11th: Mon 14, Tue 15, (16 off), Thu 17, Fri 18, Mon 21 ... + self.assertEqual( + self.company._add_business_days(date(2026, 9, 11), 3), date(2026, 9, 17) + ) + self.assertEqual( + self.company._add_business_days(date(2026, 9, 11), 8), date(2026, 9, 24) + ) + self.assertTrue(self.company._is_business_day(date(2026, 9, 17))) + self.assertFalse(self.company._is_business_day(date(2026, 9, 16))) + self.assertFalse(self.company._is_business_day(date(2026, 9, 19))) + + def test_without_calendar_the_days_are_calendar_days(self): + self.company.resource_calendar_id = False + self.env["resource.calendar"].search([]).write( + {"company_id": self.env["res.company"].create({"name": "Other"}).id} + ) + self.assertEqual( + self.company._add_business_days(date(2026, 9, 11), 8), date(2026, 9, 19) + ) + + def test_nothing_applies_on_its_own(self): + invoice = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.env["res.partner"].create({"name": "Customer"}).id, + "invoice_date": "2026-09-11", + "invoice_line_ids": [ + Command.create({"name": "Line", "quantity": 1, "price_unit": 100.0}) + ], + } + ) + invoice.action_post() + self.assertFalse(invoice.early_payment_date) + self.assertEqual(invoice.early_payment_amount, 0.0) + self.assertFalse(invoice.early_payment_can_apply) + with self.assertRaises(UserError): + invoice.action_apply_early_payment() diff --git a/account_early_payment_base/views/account_move_views.xml b/account_early_payment_base/views/account_move_views.xml new file mode 100644 index 00000000000..b608c133208 --- /dev/null +++ b/account_early_payment_base/views/account_move_views.xml @@ -0,0 +1,28 @@ + + + + account.move.form.inherit.early.payment + account.move + + + +