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 + + + +