From 635eee7b0aaf5e5eb717075d781eeca0d21c0d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Alan=20Ramos=20Rodr=C3=ADguez?= Date: Tue, 15 Sep 2026 16:43:39 -0600 Subject: [PATCH 1/5] [ADD] account_supplier_early_payment_discount: early payment discount of the supplier settled by a credit note Up to two chained discounts on the supplier, days counted from the invoice or from the receipt, the last day to pay and the discount on the vendor bill, an internal credit note reconciled with the bill when the discount is taken, and a to-do for the follow-up team until the credit note of the supplier arrives. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U2vJWgfX8LRMaFnszPEhC5 --- .../README.rst | 133 +++++ .../__init__.py | 1 + .../__manifest__.py | 20 + .../data/mail_activity_team_data.xml | 12 + .../i18n/es.po | 225 ++++++++ .../models/__init__.py | 3 + .../models/account_move.py | 184 +++++++ .../models/res_company.py | 37 ++ .../models/res_partner.py | 37 ++ .../pyproject.toml | 3 + .../readme/CONFIGURE.md | 7 + .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 23 + .../readme/USAGE.md | 11 + .../security/account_move_security.xml | 14 + .../static/description/index.html | 484 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_early_payment.py | 148 ++++++ .../views/account_move_views.xml | 33 ++ .../views/res_partner_views.xml | 18 + 20 files changed, 1396 insertions(+) create mode 100644 account_supplier_early_payment_discount/README.rst create mode 100644 account_supplier_early_payment_discount/__init__.py create mode 100644 account_supplier_early_payment_discount/__manifest__.py create mode 100644 account_supplier_early_payment_discount/data/mail_activity_team_data.xml create mode 100644 account_supplier_early_payment_discount/i18n/es.po create mode 100644 account_supplier_early_payment_discount/models/__init__.py create mode 100644 account_supplier_early_payment_discount/models/account_move.py create mode 100644 account_supplier_early_payment_discount/models/res_company.py create mode 100644 account_supplier_early_payment_discount/models/res_partner.py create mode 100644 account_supplier_early_payment_discount/pyproject.toml create mode 100644 account_supplier_early_payment_discount/readme/CONFIGURE.md create mode 100644 account_supplier_early_payment_discount/readme/CONTRIBUTORS.md create mode 100644 account_supplier_early_payment_discount/readme/DESCRIPTION.md create mode 100644 account_supplier_early_payment_discount/readme/USAGE.md create mode 100644 account_supplier_early_payment_discount/security/account_move_security.xml create mode 100644 account_supplier_early_payment_discount/static/description/index.html create mode 100644 account_supplier_early_payment_discount/tests/__init__.py create mode 100644 account_supplier_early_payment_discount/tests/test_early_payment.py create mode 100644 account_supplier_early_payment_discount/views/account_move_views.xml create mode 100644 account_supplier_early_payment_discount/views/res_partner_views.xml diff --git a/account_supplier_early_payment_discount/README.rst b/account_supplier_early_payment_discount/README.rst new file mode 100644 index 00000000000..2c078714841 --- /dev/null +++ b/account_supplier_early_payment_discount/README.rst @@ -0,0 +1,133 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============================== +Supplier Early Payment Discount +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:bc021b875827bfffea878ca957e10259abb5ade323fefb78aef4b76e74e9b22e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_supplier_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_supplier_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| + +Suppliers often grant a discount when their bills are paid within a few +days, and many of them settle it with a credit note of their own that +reaches the company weeks later, if ever. This module keeps that +discount on the supplier and on its bills, takes it when the bill is +paid in time, and follows up the credit note the supplier owes. + +- **Early payment terms on the supplier**: up to two chained discounts, + the days to pay within, and whether they count from the invoice date + or from the receipt of the goods. +- **On the vendor bill**: the last day to pay to earn the discount and + what the discount takes off. +- **Apply Early Payment**: posts an internal credit note for the + discount and reconciles it with the bill, so what is left to pay is + the net amount. +- **Follow-up**: the internal credit note raises a to-do for the *Early + Payment Credit Notes* activity team, made of the users of the *Follow + Up Early Payment Credit Notes* group, until the credit note of the + supplier arrives. + +The native early payment discount of the payment terms does not fit this +flow: it knows a single discount counted from the invoice date and books +it as a write-off when the payment is registered. Here there are two +chained discounts, a base on the receipt date, a credit note because the +supplier issues one, and someone chasing it. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +- Open a supplier, *Sales & Purchase* tab, *Early Payment* group: fill + in the discount percentages, the days and whether they count from the + invoice or from the receipt. +- Add the users who chase the credit notes of the suppliers to the group + *Follow Up Early Payment Credit Notes* (accounting managers have it by + default). They form the *Early Payment Credit Notes* activity team, + synced every time a credit note is chased. + +Usage +===== + +A posted vendor bill of a supplier with early payment terms shows the +last day to pay and the discount. Press *Apply Early Payment*: an +internal credit note for the discount is posted and reconciled with the +bill, and a to-do for the credit note the supplier owes goes to the +follow-up team, on that internal credit note. Close it when the credit +note of the supplier arrives. + +The business day the discount is paid on is exposed through +``account.move._early_payment_pay_date()``: the early payment date +itself, or the business day before when it falls on a weekend or a +holiday of the working calendar of the company. Payment order +integration lives in ``account_payment_order_early_payment_discount`` +(OCA/bank-payment). + +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_supplier_early_payment_discount/__init__.py b/account_supplier_early_payment_discount/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/account_supplier_early_payment_discount/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_supplier_early_payment_discount/__manifest__.py b/account_supplier_early_payment_discount/__manifest__.py new file mode 100644 index 00000000000..176e9e0a8f0 --- /dev/null +++ b/account_supplier_early_payment_discount/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Supplier Early Payment Discount", + "summary": "Early payment discount of the supplier 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": ["purchase_stock", "resource", "mail_activity_team"], + "data": [ + "security/account_move_security.xml", + "data/mail_activity_team_data.xml", + "views/account_move_views.xml", + "views/res_partner_views.xml", + ], + "installable": True, +} diff --git a/account_supplier_early_payment_discount/data/mail_activity_team_data.xml b/account_supplier_early_payment_discount/data/mail_activity_team_data.xml new file mode 100644 index 00000000000..8e82e9762ac --- /dev/null +++ b/account_supplier_early_payment_discount/data/mail_activity_team_data.xml @@ -0,0 +1,12 @@ + + + + + Early Payment Credit Notes + + + diff --git a/account_supplier_early_payment_discount/i18n/es.po b/account_supplier_early_payment_discount/i18n/es.po new file mode 100644 index 00000000000..b586640dc7c --- /dev/null +++ b/account_supplier_early_payment_discount/i18n/es.po @@ -0,0 +1,225 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_supplier_early_payment_discount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-15 22:50+0000\n" +"PO-Revision-Date: 2026-09-15 22:50+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_supplier_early_payment_discount +#. odoo-python +#: code:addons/account_supplier_early_payment_discount/models/account_move.py:0 +msgid "" +"%(supplier)s owes a credit note for %(amount)s of early payment discount on " +"%(bill)s, taken with this internal credit note. When its credit note " +"arrives, attach it here and close this activity." +msgstr "" +"%(supplier)s debe una nota de crédito por %(amount)s de descuento por pronto" +" pago sobre %(bill)s, tomado con esta nota de crédito interna. Cuando llegue" +" su nota de crédito, adjúntala aquí y cierra esta actividad." + +#. module: account_supplier_early_payment_discount +#. odoo-python +#: code:addons/account_supplier_early_payment_discount/models/account_move.py:0 +msgid "%s has no early payment discount." +msgstr "%s no tiene descuento por pronto pago." + +#. module: account_supplier_early_payment_discount +#: model_terms:ir.ui.view,arch_db:account_supplier_early_payment_discount.account_move_view_form +msgid "" +"A credit note for the discount will be posted and applied to this bill, and " +"an activity will follow up the credit note of the supplier. Continue?" +msgstr "" +"Se publicará una nota de crédito por el descuento y se aplicará a esta " +"factura, y una actividad dará seguimiento a la nota de crédito del " +"proveedor. ¿Continuar?" + +#. module: account_supplier_early_payment_discount +#: model_terms:ir.ui.view,arch_db:account_supplier_early_payment_discount.account_move_view_form +msgid "Apply Early Payment" +msgstr "Aplicar pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:ir.model,name:account_supplier_early_payment_discount.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: account_supplier_early_payment_discount +#: model:ir.model,name:account_supplier_early_payment_discount.model_res_partner +msgid "Contact" +msgstr "Contacto" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_account_bank_statement_line__early_payment_credit_note_id +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_account_move__early_payment_credit_note_id +msgid "" +"Credit note applied to this bill for the early payment discount. The " +"supplier still owes its own credit note; an activity follows it up." +msgstr "" +"Nota de crédito aplicada a esta factura por el descuento de pronto pago. El " +"proveedor sigue debiendo su propia nota de crédito; una actividad le da " +"seguimiento." + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_res_partner__early_payment_days +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_res_users__early_payment_days +msgid "Days to pay within to earn the discount." +msgstr "Días en los que hay que pagar para ganar el descuento." + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_res_partner__early_payment_percent +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_res_users__early_payment_percent +msgid "" +"Discount the supplier grants when its bills are paid within the early " +"payment days." +msgstr "" +"Descuento que otorga el proveedor cuando sus facturas se pagan dentro de los" +" días de pronto pago." + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_move__display_name +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_company__display_name +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_partner__display_name +msgid "Display Name" +msgstr "Nombre en pantalla" + +#. module: account_supplier_early_payment_discount +#: model_terms:ir.ui.view,arch_db:account_supplier_early_payment_discount.res_partner_view_form +msgid "Early Payment" +msgstr "Pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_bank_statement_line__early_payment_amount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_move__early_payment_amount +msgid "Early Payment Amount" +msgstr "Importe de pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_partner__early_payment_base +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_users__early_payment_base +msgid "Early Payment Counted From" +msgstr "Pronto pago contado desde" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_bank_statement_line__early_payment_credit_note_id +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_move__early_payment_credit_note_id +msgid "Early Payment Credit Note" +msgstr "Nota de crédito de pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:mail.activity.team,name:account_supplier_early_payment_discount.mail_activity_team_early_payment_credit_note +msgid "Early Payment Credit Notes" +msgstr "Notas de crédito de pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_bank_statement_line__early_payment_date +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_move__early_payment_date +msgid "Early Payment Date" +msgstr "Fecha de pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_partner__early_payment_days +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_users__early_payment_days +msgid "Early Payment Days" +msgstr "Días de pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_partner__early_payment_percent +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_users__early_payment_percent +msgid "Early Payment Discount (%)" +msgstr "Descuento por pronto pago (%)" + +#. module: account_supplier_early_payment_discount +#. odoo-python +#: code:addons/account_supplier_early_payment_discount/models/account_move.py:0 +msgid "Early payment credit note pending" +msgstr "Nota de crédito de pronto pago pendiente" + +#. module: account_supplier_early_payment_discount +#. odoo-python +#: code:addons/account_supplier_early_payment_discount/models/account_move.py:0 +msgid "Early payment discount of %s" +msgstr "Descuento por pronto pago de %s" + +#. module: account_supplier_early_payment_discount +#: model:res.groups,name:account_supplier_early_payment_discount.account_move_group_early_payment_credit_note +msgid "Follow Up Early Payment Credit Notes" +msgstr "Seguimiento de notas de crédito de pronto pago" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_account_move__id +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_company__id +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_partner__id +msgid "ID" +msgstr "" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields.selection,name:account_supplier_early_payment_discount.selection__res_partner__early_payment_base__invoice +msgid "Invoice Date" +msgstr "Fecha de factura" + +#. module: account_supplier_early_payment_discount +#: model:ir.model,name:account_supplier_early_payment_discount.model_account_move +msgid "Journal Entry" +msgstr "Asiento contable" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_account_bank_statement_line__early_payment_date +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_account_move__early_payment_date +msgid "" +"Last day to pay to earn the early payment discount of the supplier, counted " +"from the invoice or from the receipt as its conditions say." +msgstr "" +"Último día para pagar y ganar el descuento por pronto pago del proveedor, " +"contado desde la factura o desde la recepción según sus condiciones." + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields.selection,name:account_supplier_early_payment_discount.selection__res_partner__early_payment_base__receipt +msgid "Receipt Date" +msgstr "Fecha de recepción" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_partner__early_payment_percent_2 +#: model:ir.model.fields,field_description:account_supplier_early_payment_discount.field_res_users__early_payment_percent_2 +msgid "Second Early Payment Discount (%)" +msgstr "Segundo descuento por pronto pago (%)" + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_res_partner__early_payment_percent_2 +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_res_users__early_payment_percent_2 +msgid "" +"Some suppliers grant a second discount on top of the first one. It applies " +"to what is left after the first: 2 and then 1 take 2.98, not 3." +msgstr "" +"Algunos proveedores dan un segundo descuento encima del primero. Se aplica " +"sobre lo que queda tras el primero: 2 y luego 1 descuentan 2.98, no 3." + +#. module: account_supplier_early_payment_discount +#. odoo-python +#: code:addons/account_supplier_early_payment_discount/models/account_move.py:0 +msgid "The early payment discount applies to posted vendor bills only." +msgstr "" +"El descuento por pronto pago aplica solo a facturas de proveedor publicadas." + +#. module: account_supplier_early_payment_discount +#. odoo-python +#: code:addons/account_supplier_early_payment_discount/models/account_move.py:0 +msgid "The early payment discount of %s is already taken." +msgstr "El descuento por pronto pago de %s ya fue aplicado." + +#. module: account_supplier_early_payment_discount +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_account_bank_statement_line__early_payment_amount +#: model:ir.model.fields,help:account_supplier_early_payment_discount.field_account_move__early_payment_amount +msgid "What the early payment discount of the supplier takes off this bill." +msgstr "" +"Lo que el descuento por pronto pago del proveedor le quita a esta factura." diff --git a/account_supplier_early_payment_discount/models/__init__.py b/account_supplier_early_payment_discount/models/__init__.py new file mode 100644 index 00000000000..fe752a1f823 --- /dev/null +++ b/account_supplier_early_payment_discount/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_move +from . import res_company +from . import res_partner diff --git a/account_supplier_early_payment_discount/models/account_move.py b/account_supplier_early_payment_discount/models/account_move.py new file mode 100644 index 00000000000..179e9280a96 --- /dev/null +++ b/account_supplier_early_payment_discount/models/account_move.py @@ -0,0 +1,184 @@ +# 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 +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 of the " + "supplier, counted from the invoice or from the receipt as its " + "conditions say.", + ) + early_payment_amount = fields.Monetary( + compute="_compute_early_payment", + store=True, + help="What the early payment discount of the supplier takes off this bill.", + ) + early_payment_credit_note_id = fields.Many2one( + "account.move", + readonly=True, + copy=False, + help="Credit note applied to this bill for the early payment discount. " + "The supplier still owes its own credit note; an activity follows it up.", + ) + + @api.depends( + "move_type", + "invoice_date", + "amount_total", + "partner_id.commercial_partner_id.early_payment_percent", + "partner_id.commercial_partner_id.early_payment_percent_2", + "partner_id.commercial_partner_id.early_payment_days", + "partner_id.commercial_partner_id.early_payment_base", + "invoice_line_ids.purchase_line_id.order_id.picking_ids.date_done", + ) + def _compute_early_payment(self): + for move in self: + partner = move.partner_id.commercial_partner_id + ratio = partner._early_payment_ratio() if partner else 0.0 + base = ( + move._early_payment_base_date() + if move.move_type == "in_invoice" and ratio > 0 + else False + ) + move.early_payment_date = ( + base + timedelta(days=partner.early_payment_days) if base else False + ) + move.early_payment_amount = ( + move.currency_id.round(move.amount_total * ratio) if base else 0.0 + ) + + def _early_payment_base_date(self): + """Day the early payment days count from: the receipt when the + supplier says so and the goods are in, the invoice otherwise.""" + self.ensure_one() + if self.partner_id.commercial_partner_id.early_payment_base == "receipt": + pickings = ( + self.invoice_line_ids.purchase_line_id.order_id.picking_ids.filtered( + lambda picking: picking.state == "done" + and picking.picking_type_code == "incoming" + ) + ) + if pickings: + return max(pickings.mapped("date_done")).date() + return self.invoice_date + + def _early_payment_pay_date(self): + """Business day the early payment is paid on. + + The early payment date itself, or the business day before it when it + falls on a weekend or a holiday: paying after it loses the discount. + """ + self.ensure_one() + date = self.early_payment_date + if not date: + return False + company = self.company_id + return ( + date + if company._is_business_day(date) + else company._add_business_days(date, -1) + ) + + def _early_payment_applies_on(self, date): + """Whether paying on `date` earns the discount, and it is not taken yet.""" + self.ensure_one() + return bool( + self.move_type == "in_invoice" + and self.state == "posted" + and self.early_payment_date + and date <= self.early_payment_date + and self.early_payment_amount > 0 + and not self.early_payment_credit_note_id + ) + + def action_apply_early_payment(self): + """Take the early payment discount off the bill. + + A credit note for the discount is posted and reconciled with the + bill, so what is left to pay is the discounted amount. It stands for + the credit note the supplier owes, which an activity on it follows + up, so the credit notes suppliers owe are never lost track of. + """ + self.ensure_one() + if self.move_type != "in_invoice" or self.state != "posted": + raise UserError( + self.env._( + "The early payment discount applies to posted vendor bills only." + ) + ) + if self.early_payment_credit_note_id: + raise UserError( + self.env._( + "The early payment discount of %s is already taken.", + self.early_payment_credit_note_id.name, + ) + ) + partner = self.partner_id.commercial_partner_id + ratio = partner._early_payment_ratio() + if ratio <= 0: + raise UserError( + self.env._("%s has no early payment discount.", partner.display_name) + ) + today = fields.Date.context_today(self) + credit_note = self._reverse_moves( + [ + { + "invoice_date": today, + "date": today, + "ref": self.env._("Early payment discount of %s", self.name), + } + ] + ) + for line in credit_note.invoice_line_ids: + line.price_unit = line.price_unit * ratio + credit_note.action_post() + (self.line_ids + credit_note.line_ids).filtered( + lambda line: line.account_id.account_type == "liability_payable" + and not line.reconciled + ).reconcile() + self.early_payment_credit_note_id = credit_note + credit_note.activity_schedule( + "mail.mail_activity_data_todo", + summary=self.env._("Early payment credit note pending"), + note=self.env._( + "%(supplier)s owes a credit note for %(amount)s of early payment " + "discount on %(bill)s, taken with this internal credit note. When " + "its credit note arrives, attach it here and close this activity.", + supplier=partner.display_name, + amount=credit_note.amount_total, + bill=self.name, + ), + **self._early_payment_activity_assignee(), + ) + return credit_note + + def _early_payment_activity_assignee(self): + """Who chases the credit note: the follow-up team, as a whole. + + The users of the follow-up group make the members of the activity + team, so the to-do shows to every one of them and any of them closes + it. Without users in the group it falls back to the buyer of the + supplier, so the credit note is never left without an owner. + """ + self.ensure_one() + group = self.env.ref( + "account_supplier_early_payment_discount.account_move_group_early_payment_credit_note" + ) + team = self.env.ref( + "account_supplier_early_payment_discount.mail_activity_team_early_payment_credit_note" + ).sudo() + team.member_ids = group.user_ids + if team.member_ids: + return {"team_id": team.id, "user_id": False} + partner = self.partner_id.commercial_partner_id + return { + "user_id": (partner.buyer_id or self.invoice_user_id or self.env.user).id + } diff --git a/account_supplier_early_payment_discount/models/res_company.py b/account_supplier_early_payment_discount/models/res_company.py new file mode 100644 index 00000000000..4525f523f8e --- /dev/null +++ b/account_supplier_early_payment_discount/models/res_company.py @@ -0,0 +1,37 @@ +# 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`, before it when negative. + + 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) + first = start + timedelta(days=1) if days > 0 else start + begin = pytz.timezone(calendar.tz).localize(datetime.combine(first, 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_supplier_early_payment_discount/models/res_partner.py b/account_supplier_early_payment_discount/models/res_partner.py new file mode 100644 index 00000000000..c419f525d8e --- /dev/null +++ b/account_supplier_early_payment_discount/models/res_partner.py @@ -0,0 +1,37 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + early_payment_percent = fields.Float( + string="Early Payment Discount (%)", + digits=(5, 2), + help="Discount the supplier grants when its bills are paid within the " + "early payment days.", + ) + early_payment_percent_2 = fields.Float( + string="Second Early Payment Discount (%)", + digits=(5, 2), + help="Some suppliers grant a second discount on top of the first one. " + "It applies to what is left after the first: 2 and then 1 take " + "2.98, not 3.", + ) + early_payment_days = fields.Integer( + help="Days to pay within to earn the discount.", + ) + early_payment_base = fields.Selection( + [("invoice", "Invoice Date"), ("receipt", "Receipt Date")], + string="Early Payment Counted From", + default="invoice", + ) + + def _early_payment_ratio(self): + """Share of a bill the early payment takes off, both discounts chained.""" + self.ensure_one() + kept = (1 - self.early_payment_percent / 100.0) * ( + 1 - self.early_payment_percent_2 / 100.0 + ) + return 1 - kept diff --git a/account_supplier_early_payment_discount/pyproject.toml b/account_supplier_early_payment_discount/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/account_supplier_early_payment_discount/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_supplier_early_payment_discount/readme/CONFIGURE.md b/account_supplier_early_payment_discount/readme/CONFIGURE.md new file mode 100644 index 00000000000..3557587c210 --- /dev/null +++ b/account_supplier_early_payment_discount/readme/CONFIGURE.md @@ -0,0 +1,7 @@ +- Open a supplier, *Sales & Purchase* tab, *Early Payment* group: fill in the + discount percentages, the days and whether they count from the invoice or + from the receipt. +- Add the users who chase the credit notes of the suppliers to the group + *Follow Up Early Payment Credit Notes* (accounting managers have it by + default). They form the *Early Payment Credit Notes* activity team, synced + every time a credit note is chased. diff --git a/account_supplier_early_payment_discount/readme/CONTRIBUTORS.md b/account_supplier_early_payment_discount/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..5501ed298f7 --- /dev/null +++ b/account_supplier_early_payment_discount/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Jarsa + - Alan Ramos \ diff --git a/account_supplier_early_payment_discount/readme/DESCRIPTION.md b/account_supplier_early_payment_discount/readme/DESCRIPTION.md new file mode 100644 index 00000000000..f9e78a1980b --- /dev/null +++ b/account_supplier_early_payment_discount/readme/DESCRIPTION.md @@ -0,0 +1,23 @@ +Suppliers often grant a discount when their bills are paid within a few days, +and many of them settle it with a credit note of their own that reaches the +company weeks later, if ever. This module keeps that discount on the supplier +and on its bills, takes it when the bill is paid in time, and follows up the +credit note the supplier owes. + +- **Early payment terms on the supplier**: up to two chained discounts, the + days to pay within, and whether they count from the invoice date or from + the receipt of the goods. +- **On the vendor bill**: the last day to pay to earn the discount and what + the discount takes off. +- **Apply Early Payment**: posts an internal credit note for the discount and + reconciles it with the bill, so what is left to pay is the net amount. +- **Follow-up**: the internal credit note raises a to-do for the *Early + Payment Credit Notes* activity team, made of the users of the *Follow Up + Early Payment Credit Notes* group, until the credit note of the supplier + arrives. + +The native early payment discount of the payment terms does not fit this +flow: it knows a single discount counted from the invoice date and books it as +a write-off when the payment is registered. Here there are two chained +discounts, a base on the receipt date, a credit note because the supplier +issues one, and someone chasing it. diff --git a/account_supplier_early_payment_discount/readme/USAGE.md b/account_supplier_early_payment_discount/readme/USAGE.md new file mode 100644 index 00000000000..89926be5b1c --- /dev/null +++ b/account_supplier_early_payment_discount/readme/USAGE.md @@ -0,0 +1,11 @@ +A posted vendor bill of a supplier with early payment terms shows the last day +to pay and the discount. Press *Apply Early Payment*: an internal credit note +for the discount is posted and reconciled with the bill, and a to-do for the +credit note the supplier owes goes to the follow-up team, on that internal +credit note. Close it when the credit note of the supplier arrives. + +The business day the discount is paid on is exposed through +`account.move._early_payment_pay_date()`: the early payment date itself, or +the business day before when it falls on a weekend or a holiday of the +working calendar of the company. Payment order integration lives in +`account_payment_order_early_payment_discount` (OCA/bank-payment). diff --git a/account_supplier_early_payment_discount/security/account_move_security.xml b/account_supplier_early_payment_discount/security/account_move_security.xml new file mode 100644 index 00000000000..f498f3209f5 --- /dev/null +++ b/account_supplier_early_payment_discount/security/account_move_security.xml @@ -0,0 +1,14 @@ + + + + Follow Up Early Payment Credit Notes + + + + + + + diff --git a/account_supplier_early_payment_discount/static/description/index.html b/account_supplier_early_payment_discount/static/description/index.html new file mode 100644 index 00000000000..a6f27fb30f0 --- /dev/null +++ b/account_supplier_early_payment_discount/static/description/index.html @@ -0,0 +1,484 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Supplier Early Payment Discount

+ +

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

+

Suppliers often grant a discount when their bills are paid within a few +days, and many of them settle it with a credit note of their own that +reaches the company weeks later, if ever. This module keeps that +discount on the supplier and on its bills, takes it when the bill is +paid in time, and follows up the credit note the supplier owes.

+
    +
  • Early payment terms on the supplier: up to two chained discounts, +the days to pay within, and whether they count from the invoice date +or from the receipt of the goods.
  • +
  • On the vendor bill: the last day to pay to earn the discount and +what the discount takes off.
  • +
  • Apply Early Payment: posts an internal credit note for the +discount and reconciles it with the bill, so what is left to pay is +the net amount.
  • +
  • Follow-up: the internal credit note raises a to-do for the Early +Payment Credit Notes activity team, made of the users of the Follow +Up Early Payment Credit Notes group, until the credit note of the +supplier arrives.
  • +
+

The native early payment discount of the payment terms does not fit this +flow: it knows a single discount counted from the invoice date and books +it as a write-off when the payment is registered. Here there are two +chained discounts, a base on the receipt date, a credit note because the +supplier issues one, and someone chasing it.

+

Table of contents

+ +
+

Configuration

+
    +
  • Open a supplier, Sales & Purchase tab, Early Payment group: fill +in the discount percentages, the days and whether they count from the +invoice or from the receipt.
  • +
  • Add the users who chase the credit notes of the suppliers to the group +Follow Up Early Payment Credit Notes (accounting managers have it by +default). They form the Early Payment Credit Notes activity team, +synced every time a credit note is chased.
  • +
+
+
+

Usage

+

A posted vendor bill of a supplier with early payment terms shows the +last day to pay and the discount. Press Apply Early Payment: an +internal credit note for the discount is posted and reconciled with the +bill, and a to-do for the credit note the supplier owes goes to the +follow-up team, on that internal credit note. Close it when the credit +note of the supplier arrives.

+

The business day the discount is paid on is exposed through +account.move._early_payment_pay_date(): the early payment date +itself, or the business day before when it falls on a weekend or a +holiday of the working calendar of the company. Payment order +integration lives in account_payment_order_early_payment_discount +(OCA/bank-payment).

+
+
+

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_supplier_early_payment_discount/tests/__init__.py b/account_supplier_early_payment_discount/tests/__init__.py new file mode 100644 index 00000000000..19288f20f9c --- /dev/null +++ b/account_supplier_early_payment_discount/tests/__init__.py @@ -0,0 +1 @@ +from . import test_early_payment diff --git a/account_supplier_early_payment_discount/tests/test_early_payment.py b/account_supplier_early_payment_discount/tests/test_early_payment.py new file mode 100644 index 00000000000..e8eba921436 --- /dev/null +++ b/account_supplier_early_payment_discount/tests/test_early_payment.py @@ -0,0 +1,148 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import date, timedelta + +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestEarlyPayment(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.buyer = cls.env["res.users"].create( + {"name": "Buyer", "login": "early_payment_buyer"} + ) + cls.supplier = cls.env["res.partner"].create( + { + "name": "Early Payer Supplier", + "buyer_id": cls.buyer.id, + "early_payment_percent": 2.0, + "early_payment_percent_2": 1.0, + "early_payment_days": 10, + "early_payment_base": "invoice", + } + ) + cls.product = cls.env["product.product"].create( + {"name": "Bolt", "type": "consu", "is_storable": True} + ) + cls.product.supplier_taxes_id = False + cls.group = cls.env.ref( + "account_supplier_early_payment_discount.account_move_group_early_payment_credit_note" + ) + cls.team = cls.env.ref( + "account_supplier_early_payment_discount.mail_activity_team_early_payment_credit_note" + ) + + def _bill(self, partner=None, invoice_date="2026-09-10", post=True): + bill = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": (partner or self.supplier).id, + "invoice_date": invoice_date, + "invoice_date_due": invoice_date, + "ref": f"SUP-{invoice_date}", + "invoice_line_ids": [ + Command.create( + { + "product_id": self.product.id, + "quantity": 10, + "price_unit": 100.0, + } + ) + ], + } + ) + if post: + bill.action_post() + return bill + + def test_the_two_discounts_chain(self): + self.assertAlmostEqual(self.supplier._early_payment_ratio(), 0.0298) + bill = self._bill() + self.assertEqual(bill.early_payment_date, date(2026, 9, 20)) + self.assertAlmostEqual(bill.early_payment_amount, 29.8) + + def test_a_supplier_without_terms_has_no_early_payment(self): + other = self.env["res.partner"].create({"name": "Plain Supplier"}) + bill = self._bill(partner=other) + self.assertFalse(bill.early_payment_date) + self.assertEqual(bill.early_payment_amount, 0.0) + + def test_the_days_count_from_the_receipt_when_the_supplier_says_so(self): + self.supplier.early_payment_base = "receipt" + order = self.env["purchase.order"].create( + { + "partner_id": self.supplier.id, + "order_line": [ + Command.create( + { + "product_id": self.product.id, + "product_qty": 5, + "price_unit": 100.0, + } + ) + ], + } + ) + order.button_confirm() + picking = order.picking_ids + picking.move_ids.quantity = 5 + picking.button_validate() + received = date(2026, 9, 1) + picking.date_done = received + order.action_create_invoice() + bill = order.invoice_ids + bill.invoice_date = "2026-09-15" + self.assertEqual( + bill.early_payment_date, + received + timedelta(days=10), + "counted from the receipt", + ) + + def test_the_pay_date_is_the_business_day_before_a_weekend(self): + """September 26th 2026 is a Saturday, the 30th a Wednesday.""" + self.assertEqual( + self._bill(invoice_date="2026-09-16")._early_payment_pay_date(), + date(2026, 9, 25), + ) + self.assertEqual( + self._bill(invoice_date="2026-09-20")._early_payment_pay_date(), + date(2026, 9, 30), + ) + + def test_applying_the_discount_posts_a_credit_note_and_chases_the_supplier(self): + self.group.user_ids = self.env.ref("base.user_admin") + bill = self._bill() + credit_note = bill.action_apply_early_payment() + self.assertEqual(credit_note.move_type, "in_refund") + self.assertEqual(credit_note.state, "posted") + self.assertAlmostEqual(credit_note.amount_total, 29.8) + self.assertEqual(credit_note.reversed_entry_id, bill) + self.assertAlmostEqual( + bill.amount_residual, + 970.2, + msg="what is left to pay is the discounted amount", + ) + self.assertEqual(bill.early_payment_credit_note_id, credit_note) + activity = credit_note.activity_ids + self.assertEqual(activity.team_id, self.team, "the team chases it, as a whole") + self.assertFalse(activity.user_id) + self.assertIn(self.env.ref("base.user_admin"), self.team.member_ids) + self.assertIn(bill.name, activity.note) + with self.assertRaises(UserError, msg="taken once"): + bill.action_apply_early_payment() + + def test_without_anyone_in_the_group_the_buyer_chases_the_credit_note(self): + self.group.user_ids = False + credit_note = self._bill().action_apply_early_payment() + self.assertEqual(credit_note.activity_ids.user_id, self.buyer) + self.assertFalse(credit_note.activity_ids.team_id) + + def test_the_discount_needs_a_posted_bill_with_terms(self): + with self.assertRaises(UserError): + self._bill(post=False).action_apply_early_payment() + plain = self.env["res.partner"].create({"name": "Plain Supplier"}) + with self.assertRaises(UserError): + self._bill(partner=plain).action_apply_early_payment() diff --git a/account_supplier_early_payment_discount/views/account_move_views.xml b/account_supplier_early_payment_discount/views/account_move_views.xml new file mode 100644 index 00000000000..5ff4cbf17ea --- /dev/null +++ b/account_supplier_early_payment_discount/views/account_move_views.xml @@ -0,0 +1,33 @@ + + + + account.move.form.inherit.supplier.early.payment + account.move + + + +