Compare commits
1 Commits
18.0
...
18.0-accou
| Author | SHA1 | Date | |
|---|---|---|---|
| c704459de7 |
@@ -6,7 +6,7 @@
|
||||
"version": "18.0.1.0.0",
|
||||
"author": "Elabore",
|
||||
"maintainer": "False",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"website": "False",
|
||||
"license": "AGPL-3",
|
||||
"category": "False",
|
||||
"summary": "Send notification when chorus invoice failed",
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# account_invoice_default_journal
|
||||
|
||||
Forces the journal of an invoice from a journal configured on its products.
|
||||
|
||||
Two company-dependent journal fields are added on the product form, under the
|
||||
**Accounting** tab:
|
||||
|
||||
- `forced_journal_id` ("Forced sale journal", restricted to sale journals), used for
|
||||
customer invoices and credit notes (`out_invoice`, `out_refund`).
|
||||
- `forced_purchase_journal_id` ("Forced purchase journal", restricted to purchase
|
||||
journals), used for vendor bills and refunds (`in_invoice`, `in_refund`).
|
||||
|
||||
When Odoo looks for the default journal of an invoice
|
||||
(`account.move._search_default_journal()`), the journal forced by the invoice lines'
|
||||
products takes precedence over the company default. A move belongs to a single journal,
|
||||
so a `UserError` is raised if the lines force two different journals — split the
|
||||
document into two invoices in that case.
|
||||
|
||||
Both fields are company-dependent: each company configures its own forced journal on the
|
||||
same product.
|
||||
|
||||
## Installation
|
||||
|
||||
`account` is the only dependency, and it is part of Odoo core. Use the standard Odoo
|
||||
module installation procedure to install `account_invoice_default_journal`.
|
||||
|
||||
### Required access group
|
||||
|
||||
Both fields are added inside the **Accounting** tab of the product form, which comes
|
||||
from `account.product_template_form_view`. In Odoo 18.0 that tab is restricted to the
|
||||
technical group `account.group_account_readonly`, and neither
|
||||
`account.group_account_invoice` ("Invoicing") nor `account.group_account_manager`
|
||||
("Administrator") implies it — only `account.group_account_user` ("Show Full Accounting
|
||||
Features") does. Odoo 16.0 also allowed `account.group_account_invoice` on that tab, so
|
||||
the restriction is tighter than it used to be.
|
||||
|
||||
On an Invoicing-only installation, meaning without the `account_accountant` module,
|
||||
there is no standard way to obtain that group: it has to be granted explicitly.
|
||||
Otherwise both fields stay invisible even though the module is correctly installed.
|
||||
|
||||
Upgrading an Odoo 16.0 database: the values of both fields were stored in `ir.property`,
|
||||
a model removed in Odoo 17.0. The module ships
|
||||
`migrations/18.0.1.0.0/post-migration.py`, which converts them to the new `jsonb`
|
||||
storage automatically during the upgrade.
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration is needed. Set the journal on each product that needs one; a product
|
||||
with no forced journal does not affect journal selection.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open the product form and go to the **Accounting** tab.
|
||||
2. Set **Forced sale journal** and/or **Forced purchase journal**.
|
||||
|
||||
The forced journal is then applied when Odoo computes the journal of a new invoice,
|
||||
which happens only if the invoice is created together with its lines **and** without an
|
||||
explicit journal in the creation values. That is the case for invoices generated from
|
||||
another document, such as a sale order.
|
||||
|
||||
It is **not** the case when editing an invoice in the user interface: see the limitation
|
||||
below. Read it before relying on this module for manual invoicing.
|
||||
|
||||
## Known issues / Roadmap
|
||||
|
||||
### The forced journal is a default, not a constraint
|
||||
|
||||
The module plugs into `account.move._search_default_journal()`, which supplies a
|
||||
**default** value. A default is overridable by design, so the forced journal is only
|
||||
honored on one of the three ways an invoice can get its journal:
|
||||
|
||||
- Created in a single call, with its lines, and no `journal_id` in the creation values:
|
||||
the forced journal **is** applied.
|
||||
- Created in a single call with an explicit `journal_id` in the creation values: the
|
||||
forced journal is **not** applied.
|
||||
- Created first, then lines added afterwards: the forced journal is **not** applied.
|
||||
|
||||
Two independent Odoo behaviors cause the two failing cases:
|
||||
|
||||
- An explicit value in the creation values short-circuits the computation entirely, so
|
||||
the default is never requested. The web client sends `journal_id` because the field is
|
||||
present and editable in the invoice form, which puts manual invoicing in this case.
|
||||
- `_compute_journal_id` declares
|
||||
`@api.depends('move_type', 'origin_payment_id', 'statement_line_id')`. It does not
|
||||
depend on `invoice_line_ids`, so adding a product never invalidates the journal. It
|
||||
also skips any move whose journal already has a valid type, which is true of the
|
||||
company default sale journal.
|
||||
|
||||
Both behaviors are identical in Odoo 16.0, so this is a limitation of the module's
|
||||
design rather than a regression introduced by the 18.0 migration.
|
||||
|
||||
Lifting it means moving from a default to an actual constraint, by overriding `create`
|
||||
and `write`, or by refusing to save a mismatched journal. Both change behavior for
|
||||
existing users and neither is implemented.
|
||||
|
||||
### Other
|
||||
|
||||
- `_search_default_journal()` calls `super()` first, so a company with no journal of the
|
||||
expected type still raises before the forced journal is considered.
|
||||
|
||||
## Bug Tracker
|
||||
|
||||
Bugs are tracked on
|
||||
[our issues website](https://github.com/elabore-coop/account-tools/issues). In case of
|
||||
trouble, please check there if your issue has already been reported. If you spotted it
|
||||
first, help us smashing it by providing a detailed and welcomed feedback.
|
||||
|
||||
## Credits
|
||||
|
||||
### Contributors
|
||||
|
||||
- Stéphan SAINLEGER - [Github](https://github.com/stephansainleger)
|
||||
- Nicolas JEUDY - [Github](https://github.com/njeudy)
|
||||
|
||||
### Funders
|
||||
|
||||
The development of this module has been financially supported by:
|
||||
|
||||
- [Lokavaluto](https://lokavaluto.fr)
|
||||
- [Mycéliandre](https://myceliandre.fr)
|
||||
- [Elabore](https://elabore.coop)
|
||||
|
||||
### Maintainer
|
||||
|
||||
This module is maintained by Elabore.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * account_invoice_default_journal
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-20 15:05+0000\n"
|
||||
"PO-Revision-Date: 2026-10-20 15:05+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_invoice_default_journal
|
||||
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_product__forced_purchase_journal_id
|
||||
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_template__forced_purchase_journal_id
|
||||
msgid "Forced purchase journal"
|
||||
msgstr "Forcer le journal d'achat"
|
||||
|
||||
#. module: account_invoice_default_journal
|
||||
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_product__forced_journal_id
|
||||
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_template__forced_journal_id
|
||||
msgid "Forced sale journal"
|
||||
msgstr "Forcer le journal de vente"
|
||||
|
||||
#. module: account_invoice_default_journal
|
||||
#: model:ir.model,name:account_invoice_default_journal.model_account_move
|
||||
msgid "Journal Entry"
|
||||
msgstr "Pièce comptable"
|
||||
|
||||
#. module: account_invoice_default_journal
|
||||
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_product__forced_journal_id
|
||||
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_template__forced_journal_id
|
||||
msgid ""
|
||||
"Journal to use on customer invoices containing this product, instead of the "
|
||||
"company default sale journal."
|
||||
msgstr ""
|
||||
"Journal à utiliser sur les factures client contenant cet article, à la place "
|
||||
"du journal de vente par défaut de la société."
|
||||
|
||||
#. module: account_invoice_default_journal
|
||||
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_product__forced_purchase_journal_id
|
||||
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_template__forced_purchase_journal_id
|
||||
msgid ""
|
||||
"Journal to use on vendor bills containing this product, instead of the "
|
||||
"company default purchase journal."
|
||||
msgstr ""
|
||||
"Journal à utiliser sur les factures fournisseur contenant cet article, à la "
|
||||
"place du journal d'achat par défaut de la société."
|
||||
|
||||
#. module: account_invoice_default_journal
|
||||
#: model:ir.model,name:account_invoice_default_journal.model_product_template
|
||||
msgid "Product"
|
||||
msgstr "Produit"
|
||||
|
||||
#. module: account_invoice_default_journal
|
||||
#. odoo-python
|
||||
#: code:addons/account_invoice_default_journal/models/account_move.py:0
|
||||
msgid "You can not have 2 forced journals on the same invoice !"
|
||||
msgstr "Vous ne pouvez pas avoir 2 journaux forcés sur la même facture !"
|
||||
@@ -1,198 +0,0 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
"""
|
||||
Carry 16.0 ``ir.property`` values over to the 18.0 ``jsonb`` storage.
|
||||
|
||||
Odoo 17.0 removed the ``ir.property`` model: the value of a
|
||||
``company_dependent`` field now lives in a ``jsonb`` column on the model's own
|
||||
table, keyed by company id, with the cross-record fallback stored in
|
||||
``ir.default``. Nothing converts the values of a *custom* company-dependent
|
||||
field automatically, so a 16.0 database upgraded to 18.0 would silently lose
|
||||
every forced journal configured on a product.
|
||||
|
||||
This script reads the legacy ``ir_property`` rows of the two fields this module
|
||||
owns and rewrites them in the 18.0 format. It is idempotent and a no-op on a
|
||||
fresh 18.0 install, where the ``ir_property`` table no longer exists.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL = "product.template"
|
||||
TABLE = "product_template"
|
||||
FIELDS = ("forced_journal_id", "forced_purchase_journal_id")
|
||||
|
||||
|
||||
def _legacy_table_exists(cr):
|
||||
"""
|
||||
Tell whether the 16.0 ``ir_property`` table is still present.
|
||||
|
||||
:param cr: database cursor.
|
||||
:return: ``True`` when the legacy table can be read, ``False`` on a fresh
|
||||
18.0 install or once the table has been dropped.
|
||||
"""
|
||||
cr.execute("SELECT to_regclass('public.ir_property') IS NOT NULL")
|
||||
return cr.fetchone()[0]
|
||||
|
||||
|
||||
def _field_id(cr, field_name):
|
||||
"""
|
||||
Return the ``ir_model_fields`` id of a field of :data:`MODEL`.
|
||||
|
||||
Only the ``product.template`` definition is looked up on purpose: the
|
||||
``product.product`` counterpart is a delegated related field and never
|
||||
carries ``ir.property`` rows of its own.
|
||||
|
||||
:param cr: database cursor.
|
||||
:param str field_name: name of the field to look up.
|
||||
:return: the field id, or ``None`` when the field is unknown to the
|
||||
database (module never installed before the upgrade).
|
||||
"""
|
||||
cr.execute(
|
||||
"SELECT id FROM ir_model_fields WHERE model = %s AND name = %s",
|
||||
(MODEL, field_name),
|
||||
)
|
||||
row = cr.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _migrate_per_record_values(cr, field_name, field_id):
|
||||
"""
|
||||
Move per-product legacy values into the ``jsonb`` column.
|
||||
|
||||
Existing keys in the target column win, which makes the operation
|
||||
idempotent and safe to re-run after a partial upgrade. Properties pointing
|
||||
at a journal that no longer exists are dropped, since the ``jsonb``
|
||||
storage of a company-dependent many2one has no foreign key to protect it.
|
||||
|
||||
:param cr: database cursor.
|
||||
:param str field_name: name of the company-dependent field to fill.
|
||||
:param int field_id: ``ir_model_fields`` id of that field.
|
||||
:return: number of products updated.
|
||||
"""
|
||||
cr.execute(
|
||||
f"""
|
||||
WITH legacy AS (
|
||||
SELECT
|
||||
split_part(ip.res_id, ',', 2)::integer AS product_tmpl_id,
|
||||
ip.company_id AS company_id,
|
||||
split_part(ip.value_reference, ',', 2)::integer AS journal_id
|
||||
FROM ir_property ip
|
||||
WHERE ip.fields_id = %s
|
||||
AND ip.type = 'many2one'
|
||||
AND ip.value_reference IS NOT NULL
|
||||
AND ip.res_id IS NOT NULL
|
||||
AND ip.company_id IS NOT NULL
|
||||
AND split_part(ip.res_id, ',', 1) = %s
|
||||
),
|
||||
checked AS (
|
||||
SELECT legacy.*
|
||||
FROM legacy
|
||||
JOIN account_journal aj ON aj.id = legacy.journal_id
|
||||
JOIN {TABLE} pt ON pt.id = legacy.product_tmpl_id
|
||||
JOIN res_company rc ON rc.id = legacy.company_id
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
product_tmpl_id,
|
||||
jsonb_object_agg(company_id::text, journal_id) AS value
|
||||
FROM checked
|
||||
GROUP BY product_tmpl_id
|
||||
)
|
||||
UPDATE {TABLE} pt
|
||||
SET {field_name} = grouped.value || COALESCE(pt.{field_name}, '{{}}'::jsonb)
|
||||
FROM grouped
|
||||
WHERE pt.id = grouped.product_tmpl_id
|
||||
""",
|
||||
(field_id, MODEL),
|
||||
)
|
||||
return cr.rowcount
|
||||
|
||||
|
||||
def _migrate_fallback_values(cr, field_name, field_id):
|
||||
"""
|
||||
Move legacy cross-record defaults into ``ir_default``.
|
||||
|
||||
An ``ir_property`` row without ``res_id`` was a default applying to every
|
||||
record of the model. In 18.0 that role belongs to ``ir.default``, which is
|
||||
what :meth:`odoo.fields.Field.get_company_dependent_fallback` reads.
|
||||
|
||||
Rows are only inserted when no default exists yet for the same field and
|
||||
company, keeping the operation idempotent.
|
||||
|
||||
:param cr: database cursor.
|
||||
:param str field_name: name of the company-dependent field.
|
||||
:param int field_id: ``ir_model_fields`` id of that field.
|
||||
:return: number of defaults created.
|
||||
"""
|
||||
cr.execute(
|
||||
"""
|
||||
INSERT INTO ir_default (field_id, user_id, company_id, condition, json_value)
|
||||
SELECT %s, NULL, ip.company_id, NULL,
|
||||
split_part(ip.value_reference, ',', 2)
|
||||
FROM ir_property ip
|
||||
JOIN account_journal aj
|
||||
ON aj.id = split_part(ip.value_reference, ',', 2)::integer
|
||||
JOIN res_company rc ON rc.id = ip.company_id
|
||||
WHERE ip.fields_id = %s
|
||||
AND ip.type = 'many2one'
|
||||
AND ip.value_reference IS NOT NULL
|
||||
AND ip.res_id IS NULL
|
||||
AND ip.company_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ir_default d
|
||||
WHERE d.field_id = %s
|
||||
AND d.company_id = ip.company_id
|
||||
AND d.user_id IS NULL
|
||||
AND d.condition IS NULL
|
||||
)
|
||||
""",
|
||||
(field_id, field_id, field_id),
|
||||
)
|
||||
if cr.rowcount:
|
||||
_logger.info(
|
||||
"%s: converted %s company fallback(s) from ir.property to ir.default",
|
||||
field_name,
|
||||
cr.rowcount,
|
||||
)
|
||||
return cr.rowcount
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Convert the module's legacy ``ir.property`` rows to the 18.0 storage.
|
||||
|
||||
Runs after the module schema has been updated, so the ``jsonb`` columns
|
||||
this script writes into already exist.
|
||||
|
||||
:param cr: database cursor.
|
||||
:param str version: version the module is upgraded from. Falsy on a fresh
|
||||
install, in which case there is nothing to convert.
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
if not _legacy_table_exists(cr):
|
||||
_logger.info(
|
||||
"ir_property table is gone, nothing to convert for %s",
|
||||
", ".join(FIELDS),
|
||||
)
|
||||
return
|
||||
|
||||
for field_name in FIELDS:
|
||||
field_id = _field_id(cr, field_name)
|
||||
if not field_id:
|
||||
_logger.warning(
|
||||
"%s.%s not found in ir_model_fields, skipping its conversion",
|
||||
MODEL,
|
||||
field_name,
|
||||
)
|
||||
continue
|
||||
updated = _migrate_per_record_values(cr, field_name, field_id)
|
||||
_migrate_fallback_values(cr, field_name, field_id)
|
||||
_logger.info(
|
||||
"%s.%s: %s product(s) migrated from ir.property to jsonb storage",
|
||||
MODEL,
|
||||
field_name,
|
||||
updated,
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
from . import product_template
|
||||
from . import account_move
|
||||
@@ -1,64 +0,0 @@
|
||||
# Copyright 2022 Nicolas JEUDY
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import _, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
def _get_forced_journal(self):
|
||||
"""
|
||||
Return the journal forced by the products of this move.
|
||||
|
||||
The journal is read from the product of each invoice line:
|
||||
``forced_journal_id`` for customer documents, and
|
||||
``forced_purchase_journal_id`` for vendor documents. Products
|
||||
carrying no forced journal are ignored.
|
||||
|
||||
Only the first record of ``self`` is considered, mirroring the
|
||||
single-record call sites in ``_search_default_journal``.
|
||||
|
||||
:return: the single forced ``account.journal`` record when exactly one
|
||||
product forces a journal. Otherwise the move's current
|
||||
``journal_id``, which is also what non-invoice moves return. An
|
||||
empty ``account.journal`` recordset when ``self`` is empty.
|
||||
:raises UserError: if the lines force more than one distinct journal,
|
||||
since a move belongs to exactly one journal.
|
||||
"""
|
||||
journal_model = self.env["account.journal"]
|
||||
for record in self:
|
||||
if record.move_type in ("out_invoice", "out_refund"):
|
||||
forced_journals = record.invoice_line_ids.product_id.forced_journal_id
|
||||
elif record.move_type in ("in_invoice", "in_refund"):
|
||||
forced_journals = (
|
||||
record.invoice_line_ids.product_id.forced_purchase_journal_id
|
||||
)
|
||||
else:
|
||||
forced_journals = journal_model
|
||||
if len(forced_journals) > 1:
|
||||
raise UserError(
|
||||
_("You can not have 2 forced journals on the same invoice !")
|
||||
)
|
||||
if len(forced_journals) == 1:
|
||||
return forced_journals
|
||||
return record.journal_id
|
||||
return journal_model
|
||||
|
||||
def _search_default_journal(self):
|
||||
"""
|
||||
Prefer the journal forced by the move products over the default one.
|
||||
|
||||
Extends the standard journal lookup so that a journal configured on a
|
||||
product takes precedence. Falls back to the standard behavior when no
|
||||
product forces a journal.
|
||||
|
||||
:return: the ``account.journal`` record to default to.
|
||||
"""
|
||||
res = super()._search_default_journal()
|
||||
forced_journal = self._get_forced_journal()
|
||||
if forced_journal:
|
||||
return forced_journal
|
||||
return res
|
||||
@@ -1,28 +0,0 @@
|
||||
# Copyright 2022 Nicolas JEUDY
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
forced_journal_id = fields.Many2one(
|
||||
"account.journal",
|
||||
company_dependent=True,
|
||||
check_company=True,
|
||||
string="Forced sale journal",
|
||||
domain="[('type', '=', 'sale')]",
|
||||
help="Journal to use on customer invoices containing this product, "
|
||||
"instead of the company default sale journal.",
|
||||
)
|
||||
forced_purchase_journal_id = fields.Many2one(
|
||||
"account.journal",
|
||||
company_dependent=True,
|
||||
check_company=True,
|
||||
string="Forced purchase journal",
|
||||
domain="[('type', '=', 'purchase')]",
|
||||
help="Journal to use on vendor bills containing this product, "
|
||||
"instead of the company default purchase journal.",
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
Copyright 2022 Nicolas JEUDY
|
||||
Copyright 2026 Elabore
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
<odoo>
|
||||
<record model="ir.ui.view" id="view_product_template_forced_journal_form">
|
||||
<field name="name">product.template.forced_journal.form.inherit</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="account.product_template_form_view" />
|
||||
<field name="arch" type="xml">
|
||||
<!--
|
||||
No `domain` attribute here on purpose: a view-level domain
|
||||
overrides the one generated from the field definition, which
|
||||
already combines the journal type with the `check_company`
|
||||
company domain.
|
||||
-->
|
||||
<field name="property_account_income_id" position="after">
|
||||
<field name="forced_journal_id" />
|
||||
</field>
|
||||
<field name="property_account_expense_id" position="after">
|
||||
<field name="forced_purchase_journal_id" />
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
"license": "AGPL-3",
|
||||
"summary": "Transfer the Receipt Lost value in invoices and account move lines",
|
||||
"author": "Elabore",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"website": "https://elabore.coop/",
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"name": "account_move_attachement_zipped_download",
|
||||
"version": "18.0.1.0.0",
|
||||
"author": "Elabore",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"website": "https://elabore.coop",
|
||||
"maintainer": "Boris Gallet",
|
||||
"license": "AGPL-3",
|
||||
"category": "Tools",
|
||||
|
||||
33
account_move_last_payment/README.md
Normal file
33
account_move_last_payment/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Account Move Last Payment
|
||||
|
||||
Expose on `account.move` the date and method of the most recent real payment reconciled
|
||||
with an invoice, as two stored, exportable fields shown in the invoice list view.
|
||||
|
||||
## Features
|
||||
|
||||
- `last_payment_date` (Date): date of the most recent payment reconciled with the
|
||||
invoice — partial payments included.
|
||||
- `last_payment_method` (Char): method of that most recent payment: the payment method
|
||||
line name for explicit payments, "Bank Transfer" when the invoice was settled through
|
||||
a direct bank statement line reconciliation.
|
||||
- Covers both Community reconciliation paths: explicit payment ("Register Payment"
|
||||
wizard) and direct bank statement reconciliation. Manual reconciliations without
|
||||
payment or statement line are ignored, as are exchange difference moves.
|
||||
- Recomputes automatically on reconcile / unreconcile and when a payment or statement
|
||||
line date changes.
|
||||
- Both fields are stored and displayed as optional columns of the invoice list view, so
|
||||
they are exportable.
|
||||
|
||||
## Installation
|
||||
|
||||
Depends on the `account` module only.
|
||||
|
||||
## Credits
|
||||
|
||||
### Contributors
|
||||
|
||||
- Elabore - Assisted by AI
|
||||
|
||||
### Maintainer
|
||||
|
||||
- Elabore
|
||||
@@ -1,20 +1,19 @@
|
||||
# Copyright 2022 Nicolas JEUDY
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "Account Invoice Default Journal",
|
||||
"name": "Account Move Last Payment",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Accounting",
|
||||
"summary": "Force the invoice journal from a journal set on the product",
|
||||
"author": "Nicolas JEUDY, Elabore",
|
||||
"summary": "Last real payment date and method on invoices",
|
||||
"author": "Elabore",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"license": "AGPL-3",
|
||||
"category": "Accounting",
|
||||
"depends": [
|
||||
"account",
|
||||
],
|
||||
"data": [
|
||||
"views/product_template_view.xml",
|
||||
"views/account_move_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
54
account_move_last_payment/i18n/fr.po
Normal file
54
account_move_last_payment/i18n/fr.po
Normal file
@@ -0,0 +1,54 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * account_move_last_payment
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-20 15:23+0000\n"
|
||||
"PO-Revision-Date: 2026-10-20 15:23+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_move_last_payment
|
||||
#: code:addons/account_move_last_payment/models/account_move.py:0
|
||||
msgid "Bank Transfer"
|
||||
msgstr "Virement"
|
||||
|
||||
#. module: account_move_last_payment
|
||||
#: model:ir.model.fields,field_description:account_move_last_payment.field_account_move__last_payment_date
|
||||
msgid "Last Payment Date"
|
||||
msgstr "Date du dernier paiement"
|
||||
|
||||
#. module: account_move_last_payment
|
||||
#: model:ir.model.fields,field_description:account_move_last_payment.field_account_move__last_payment_method
|
||||
msgid "Last Payment Method"
|
||||
msgstr "Mode de paiement"
|
||||
|
||||
#. module: account_move_last_payment
|
||||
#: model:ir.model.fields,help:account_move_last_payment.field_account_move__last_payment_date
|
||||
msgid ""
|
||||
"Date of the most recent payment reconciled with this invoice, whatever "
|
||||
"the reconciliation path (explicit payment or direct bank statement line "
|
||||
"reconciliation)."
|
||||
msgstr ""
|
||||
"Date du paiement le plus récent lettré avec cette facture, quel que soit "
|
||||
"le chemin de lettrage (paiement explicite ou rapprochement direct d'une "
|
||||
"ligne de relevé bancaire)."
|
||||
|
||||
#. module: account_move_last_payment
|
||||
#: model:ir.model.fields,help:account_move_last_payment.field_account_move__last_payment_method
|
||||
msgid ""
|
||||
"Payment method of the most recent payment reconciled with this invoice."
|
||||
msgstr ""
|
||||
"Mode de paiement du paiement le plus récent lettré avec cette facture."
|
||||
|
||||
#. module: account_move_last_payment
|
||||
#: model:ir.model,name:account_move_last_payment.model_account_move
|
||||
msgid "Journal Entry"
|
||||
msgstr "Écriture comptable"
|
||||
1
account_move_last_payment/models/__init__.py
Normal file
1
account_move_last_payment/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import account_move
|
||||
84
account_move_last_payment/models/account_move.py
Normal file
84
account_move_last_payment/models/account_move.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
last_payment_date = fields.Date(
|
||||
compute="_compute_last_payment_info",
|
||||
store=True,
|
||||
copy=False,
|
||||
help="Date of the most recent payment reconciled with this "
|
||||
"invoice, whatever the reconciliation path (explicit payment "
|
||||
"or direct bank statement line reconciliation).",
|
||||
)
|
||||
last_payment_method = fields.Char(
|
||||
compute="_compute_last_payment_info",
|
||||
store=True,
|
||||
copy=False,
|
||||
help="Payment method of the most recent payment reconciled with this invoice.",
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
"line_ids.matched_debit_ids.debit_move_id.date",
|
||||
"line_ids.matched_debit_ids.debit_move_id.move_id.origin_payment_id.payment_method_line_id.name",
|
||||
"line_ids.matched_debit_ids.debit_move_id.move_id.statement_line_id",
|
||||
"line_ids.matched_credit_ids.credit_move_id.date",
|
||||
"line_ids.matched_credit_ids.credit_move_id.move_id.origin_payment_id.payment_method_line_id.name",
|
||||
"line_ids.matched_credit_ids.credit_move_id.move_id.statement_line_id",
|
||||
)
|
||||
def _compute_last_payment_info(self):
|
||||
"""Compute the last real payment date and method.
|
||||
|
||||
Only customer and vendor invoices carry the information; every
|
||||
other move type gets both fields cleared. Candidates are the
|
||||
reconciliation counterpart lines of the receivable/payable
|
||||
lines (exchange difference moves excluded). The most recent
|
||||
candidate wins; on equal dates the first candidate found wins.
|
||||
"""
|
||||
for move in self:
|
||||
if move.move_type not in ("in_invoice", "out_invoice"):
|
||||
move.last_payment_date = False
|
||||
move.last_payment_method = False
|
||||
continue
|
||||
candidates = move._get_last_payment_candidates()
|
||||
if not candidates:
|
||||
move.last_payment_date = False
|
||||
move.last_payment_method = False
|
||||
else:
|
||||
latest_date, latest_method = max(
|
||||
candidates, key=lambda candidate: candidate[0]
|
||||
)
|
||||
move.last_payment_date = latest_date
|
||||
move.last_payment_method = latest_method
|
||||
|
||||
def _get_last_payment_candidates(self):
|
||||
"""Collect the candidate real payments for this invoice.
|
||||
|
||||
A candidate is a ``(date, method)`` tuple built from each
|
||||
reconciliation counterpart line:
|
||||
|
||||
- counterpart move with ``origin_payment_id``: explicit
|
||||
payment, dated with the payment date, method from the
|
||||
payment method line;
|
||||
- counterpart move with ``statement_line_id``: direct bank
|
||||
statement reconciliation, dated with the counterpart move
|
||||
date, method fixed to the bank transfer label;
|
||||
- any other manual reconciliation is ignored.
|
||||
|
||||
:return: list of ``(date, method)`` tuples, possibly empty.
|
||||
"""
|
||||
self.ensure_one()
|
||||
invoice_partials, exchange_diff_moves = self._get_reconciled_invoices_partials()
|
||||
candidates = []
|
||||
for _partial, _amount, counterpart_line in invoice_partials:
|
||||
if counterpart_line.move_id.id in exchange_diff_moves:
|
||||
continue
|
||||
payment = counterpart_line.move_id.origin_payment_id
|
||||
if payment:
|
||||
candidates.append((payment.date, payment.payment_method_line_id.name))
|
||||
elif counterpart_line.move_id.statement_line_id:
|
||||
candidates.append(
|
||||
(counterpart_line.move_id.date, self.env._("Bank Transfer"))
|
||||
)
|
||||
return candidates
|
||||
1
account_move_last_payment/tests/__init__.py
Normal file
1
account_move_last_payment/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_last_payment
|
||||
239
account_move_last_payment/tests/test_last_payment.py
Normal file
239
account_move_last_payment/tests/test_last_payment.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from odoo import fields
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestAccountMoveLastPayment(AccountTestInvoicingCommon):
|
||||
"""Test the last payment date and method stored on invoices."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
|
||||
cls.inbound_method_line = cls.company_data[
|
||||
"default_journal_bank"
|
||||
].inbound_payment_method_line_ids[0]
|
||||
|
||||
@classmethod
|
||||
def _create_posted_invoice(cls, move_type="out_invoice"):
|
||||
"""Create and post a simple invoice without taxes.
|
||||
|
||||
:param str move_type: the invoice type to create.
|
||||
:return: the posted account.move record.
|
||||
"""
|
||||
return cls.init_invoice(move_type, post=True, amounts=[1000.0], taxes=[])
|
||||
|
||||
@classmethod
|
||||
def _register_payment(cls, invoice, date=None, method_line=None, amount=None):
|
||||
"""Register a payment on an invoice through the payment wizard.
|
||||
|
||||
:param invoice: the account.move to pay.
|
||||
:param date: the payment date (defaults to today).
|
||||
:param method_line: the account.payment.method.line to use
|
||||
(defaults to the bank journal inbound method line).
|
||||
:param amount: the payment amount (defaults to the invoice
|
||||
residual).
|
||||
:return: the created account.payment record.
|
||||
"""
|
||||
return (
|
||||
cls.env["account.payment.register"]
|
||||
.with_context(
|
||||
active_model="account.move",
|
||||
active_ids=invoice.ids,
|
||||
)
|
||||
.create(
|
||||
{
|
||||
"payment_date": date or fields.Date.today(),
|
||||
"payment_method_line_id": (
|
||||
method_line or cls.inbound_method_line
|
||||
).id,
|
||||
"amount": amount if amount is not None else invoice.amount_residual,
|
||||
}
|
||||
)
|
||||
._create_payments()
|
||||
)
|
||||
|
||||
def _reconcile_statement_line(self, invoice, amount, date=None):
|
||||
"""Create a bank statement line and reconcile it with the invoice.
|
||||
|
||||
Mirrors the core reconciliation path: the statement line suspense
|
||||
counterpart is re-accounted to the invoice receivable account,
|
||||
then both lines are reconciled together.
|
||||
|
||||
:param invoice: the account.move to settle.
|
||||
:param float amount: the statement line amount.
|
||||
:param date: the statement line date (defaults to today).
|
||||
:return: the created account.bank.statement.line record.
|
||||
"""
|
||||
statement_line = self.env["account.bank.statement.line"].create(
|
||||
{
|
||||
"payment_ref": invoice.name,
|
||||
"journal_id": self.company_data["default_journal_bank"].id,
|
||||
"partner_id": invoice.partner_id.id,
|
||||
"amount": amount,
|
||||
"date": date or fields.Date.today(),
|
||||
}
|
||||
)
|
||||
st_liquidity_lines, st_suspense_lines, _ = statement_line._seek_for_lines()
|
||||
receivable_lines = invoice.line_ids.filtered(
|
||||
lambda line: line.account_type == "asset_receivable"
|
||||
)
|
||||
st_suspense_lines.account_id = receivable_lines.account_id
|
||||
(st_suspense_lines + receivable_lines).reconcile()
|
||||
return statement_line
|
||||
|
||||
def test_register_payment(self):
|
||||
"""Explicit payment: fields match the payment date and method."""
|
||||
invoice = self._create_posted_invoice()
|
||||
payment = self._register_payment(invoice)
|
||||
self.assertEqual(invoice.last_payment_date, payment.date)
|
||||
self.assertEqual(
|
||||
invoice.last_payment_method, payment.payment_method_line_id.name
|
||||
)
|
||||
|
||||
def test_bank_statement_reconciliation(self):
|
||||
"""Direct statement reconciliation: statement date + Bank Transfer."""
|
||||
invoice = self._create_posted_invoice()
|
||||
yesterday = fields.Date.subtract(fields.Date.today(), days=1)
|
||||
statement_line = self._reconcile_statement_line(
|
||||
invoice, invoice.amount_total, date=yesterday
|
||||
)
|
||||
self.assertEqual(invoice.last_payment_date, statement_line.date)
|
||||
self.assertEqual(invoice.last_payment_method, "Bank Transfer")
|
||||
|
||||
def test_list_view_and_export(self):
|
||||
"""Both fields are stored and shown in the invoice list view."""
|
||||
self.assertTrue(self.env["account.move"]._fields["last_payment_date"].store)
|
||||
self.assertTrue(self.env["account.move"]._fields["last_payment_method"].store)
|
||||
view = self.env["ir.ui.view"]._get_view_id("account.view_invoice_tree")
|
||||
arch = self.env["account.move"].get_view(view_id=view, view_type="list")["arch"]
|
||||
self.assertIn('name="last_payment_date"', arch)
|
||||
self.assertIn('name="last_payment_method"', arch)
|
||||
|
||||
def test_multiple_payments_keeps_latest(self):
|
||||
"""With several payments, the most recent candidate wins."""
|
||||
invoice = self._create_posted_invoice()
|
||||
older_date = fields.Date.subtract(fields.Date.today(), days=10)
|
||||
newer_date = fields.Date.subtract(fields.Date.today(), days=2)
|
||||
self._register_payment(invoice, date=older_date, amount=400.0)
|
||||
self._register_payment(invoice, date=newer_date, amount=600.0)
|
||||
self.assertEqual(invoice.last_payment_date, newer_date)
|
||||
self.assertEqual(invoice.last_payment_method, self.inbound_method_line.name)
|
||||
|
||||
def test_statement_line_date_change_recompute(self):
|
||||
"""Changing the statement line date updates the stored fields."""
|
||||
invoice = self._create_posted_invoice()
|
||||
yesterday = fields.Date.subtract(fields.Date.today(), days=1)
|
||||
statement_line = self._reconcile_statement_line(
|
||||
invoice, invoice.amount_total, date=yesterday
|
||||
)
|
||||
self.assertEqual(invoice.last_payment_date, yesterday)
|
||||
older_date = fields.Date.subtract(fields.Date.today(), days=30)
|
||||
statement_line.date = older_date
|
||||
self.assertEqual(invoice.last_payment_date, older_date)
|
||||
|
||||
def test_unreconcile_clears_fields(self):
|
||||
"""Unreconciling the invoice clears both fields."""
|
||||
invoice = self._create_posted_invoice()
|
||||
self._register_payment(invoice)
|
||||
invoice.line_ids.remove_move_reconcile()
|
||||
self.assertFalse(invoice.last_payment_date)
|
||||
self.assertFalse(invoice.last_payment_method)
|
||||
|
||||
def _setup_eur_rates(self, invoice_date, payment_date):
|
||||
"""Set deterministic EUR rates so the payment creates an
|
||||
exchange difference move.
|
||||
|
||||
:param invoice_date: the invoice posting date.
|
||||
:param payment_date: the payment date.
|
||||
:return: the EUR res.currency record.
|
||||
"""
|
||||
eur = self.env.ref("base.EUR")
|
||||
if not eur.active:
|
||||
eur.active = True
|
||||
self.env["res.currency.rate"].search(
|
||||
[
|
||||
("currency_id", "=", eur.id),
|
||||
("name", "in", [invoice_date, payment_date]),
|
||||
]
|
||||
).unlink()
|
||||
self.env["res.currency.rate"].create(
|
||||
[
|
||||
{"currency_id": eur.id, "name": invoice_date, "rate": 1.0},
|
||||
{"currency_id": eur.id, "name": payment_date, "rate": 1.1},
|
||||
]
|
||||
)
|
||||
return eur
|
||||
|
||||
def test_multicurrency_invoice(self):
|
||||
"""A foreign currency payment keeps its real date and method,
|
||||
and the generated exchange difference move is not taken as a
|
||||
payment candidate."""
|
||||
payment_date = fields.Date.subtract(fields.Date.today(), days=5)
|
||||
invoice_date = fields.Date.from_string("2019-01-01")
|
||||
eur = self._setup_eur_rates(invoice_date, payment_date)
|
||||
invoice = self.init_invoice("out_invoice", post=False, amounts=[1000.0])
|
||||
invoice.currency_id = eur
|
||||
invoice.action_post()
|
||||
payment = self._register_payment(
|
||||
invoice,
|
||||
date=payment_date,
|
||||
amount=invoice.amount_residual,
|
||||
)
|
||||
self.assertTrue(invoice.line_ids.mapped("matched_credit_ids.exchange_move_id"))
|
||||
candidates = invoice._get_last_payment_candidates()
|
||||
self.assertEqual(len(candidates), 1)
|
||||
self.assertEqual(invoice.last_payment_date, payment.date)
|
||||
self.assertEqual(
|
||||
invoice.last_payment_method, payment.payment_method_line_id.name
|
||||
)
|
||||
|
||||
def test_draft_invoice_is_empty(self):
|
||||
"""A draft invoice has never been reconciled, so both fields
|
||||
stay empty."""
|
||||
invoice = self.init_invoice("out_invoice", post=False, amounts=[1000.0])
|
||||
self.assertFalse(invoice.last_payment_date)
|
||||
self.assertFalse(invoice.last_payment_method)
|
||||
|
||||
def test_non_invoice_moves_are_empty(self):
|
||||
"""Journal entries and credit notes never carry the fields."""
|
||||
invoice = self._create_posted_invoice()
|
||||
self._register_payment(invoice)
|
||||
refund = self._create_posted_invoice(move_type="out_refund")
|
||||
entry = self.env["account.move"].create(
|
||||
{
|
||||
"move_type": "entry",
|
||||
"journal_id": self.company_data["default_journal_misc"].id,
|
||||
"date": fields.Date.today(),
|
||||
"line_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"account_id": self.company_data[
|
||||
"default_account_revenue"
|
||||
].id,
|
||||
"debit": 100.0,
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"account_id": self.company_data[
|
||||
"default_account_receivable"
|
||||
].id,
|
||||
"credit": 100.0,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
entry.action_post()
|
||||
self.assertFalse(refund.last_payment_date)
|
||||
self.assertFalse(refund.last_payment_method)
|
||||
self.assertFalse(entry.last_payment_date)
|
||||
self.assertFalse(entry.last_payment_method)
|
||||
self.assertTrue(invoice.last_payment_date)
|
||||
14
account_move_last_payment/views/account_move_views.xml
Normal file
14
account_move_last_payment/views/account_move_views.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_invoice_tree_last_payment" model="ir.ui.view">
|
||||
<field name="name">account.invoice.list.last.payment</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_invoice_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="amount_residual_signed" position="after">
|
||||
<field name="last_payment_date" optional="show"/>
|
||||
<field name="last_payment_method" optional="show"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "18.0.1.0.0",
|
||||
"summary": "Add account number in partner",
|
||||
"author": "Elabore",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"website": "https://elabore.coop/",
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"name": "Account statement import online ponto for Credit Cooperatif",
|
||||
"version": "18.0.1.0.0",
|
||||
"author": "Elabore",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"website": "https://elabore.coop",
|
||||
"maintainer": "Quentin Mondot",
|
||||
"license": "AGPL-3",
|
||||
"category": "Tools",
|
||||
|
||||
Reference in New Issue
Block a user