Compare commits
4 Commits
18.0-accou
...
18.0-tax_r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb3fe766b | ||
|
|
73c0684623 | ||
|
|
e14eafed39 | ||
|
|
78c6de7ecb |
@@ -6,7 +6,7 @@
|
||||
"version": "18.0.1.0.0",
|
||||
"author": "Elabore",
|
||||
"maintainer": "False",
|
||||
"website": "False",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"license": "AGPL-3",
|
||||
"category": "False",
|
||||
"summary": "Send notification when chorus invoice failed",
|
||||
|
||||
125
account_invoice_default_journal/README.md
Normal file
125
account_invoice_default_journal/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 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
account_invoice_default_journal/__init__.py
Normal file
1
account_invoice_default_journal/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
22
account_invoice_default_journal/__manifest__.py
Normal file
22
account_invoice_default_journal/__manifest__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copyright 2022 Nicolas JEUDY
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "Account Invoice Default Journal",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Accounting",
|
||||
"summary": "Force the invoice journal from a journal set on the product",
|
||||
"author": "Nicolas JEUDY, Elabore",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"license": "AGPL-3",
|
||||
"depends": [
|
||||
"account",
|
||||
],
|
||||
"data": [
|
||||
"views/product_template_view.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"application": False,
|
||||
}
|
||||
64
account_invoice_default_journal/i18n/fr.po
Normal file
64
account_invoice_default_journal/i18n/fr.po
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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 !"
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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,
|
||||
)
|
||||
2
account_invoice_default_journal/models/__init__.py
Normal file
2
account_invoice_default_journal/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import product_template
|
||||
from . import account_move
|
||||
64
account_invoice_default_journal/models/account_move.py
Normal file
64
account_invoice_default_journal/models/account_move.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
28
account_invoice_default_journal/models/product_template.py
Normal file
28
account_invoice_default_journal/models/product_template.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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.",
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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://elabore.coop/",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"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://elabore.coop",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"maintainer": "Boris Gallet",
|
||||
"license": "AGPL-3",
|
||||
"category": "Tools",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "18.0.1.0.0",
|
||||
"summary": "Add account number in partner",
|
||||
"author": "Elabore",
|
||||
"website": "https://elabore.coop/",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"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://elabore.coop",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"maintainer": "Quentin Mondot",
|
||||
"license": "AGPL-3",
|
||||
"category": "Tools",
|
||||
|
||||
209
tax_receipt_in_portal/README.rst
Normal file
209
tax_receipt_in_portal/README.rst
Normal file
@@ -0,0 +1,209 @@
|
||||
=====================
|
||||
tax_receipt_in_portal
|
||||
=====================
|
||||
|
||||
Let donors download their own tax receipts from the Odoo portal.
|
||||
|
||||
Description
|
||||
===========
|
||||
|
||||
This module publishes the tax receipts managed by ``l10n_fr_tax_receipt``
|
||||
to the donor they were issued to:
|
||||
|
||||
* a **Tax Receipts** card on ``/my/home``, with a counter;
|
||||
* a paginated list on ``/my/tax-receipts``;
|
||||
* a detail page on ``/my/tax-receipts/<id>`` with the receipt
|
||||
information, its invoices and the **portal chatter**, where the donor
|
||||
can talk to the association;
|
||||
* a per-receipt PDF download on ``/my/tax-receipts/<id>/download``.
|
||||
|
||||
The invoices section only lists invoices the donor is actually allowed to
|
||||
read — Odoo auto-subscribes a partner linked to a user when the invoice is
|
||||
created, which satisfies the ``account.move`` portal rule. Unreadable
|
||||
invoices are hidden rather than shown as dead links.
|
||||
|
||||
The PDF is produced by the ``pdf_form`` report configured for the receipt
|
||||
model and is **rendered on demand**: nothing is archived. This is sound
|
||||
because ``l10n_fr_tax_receipt`` freezes a receipt once confirmed — its
|
||||
invoice set can no longer change and its amount is stored — so rendering
|
||||
the same receipt twice yields the same document.
|
||||
|
||||
Responsibility split
|
||||
--------------------
|
||||
|
||||
=========================== ==================================================
|
||||
Module Responsibility
|
||||
=========================== ==================================================
|
||||
``l10n_fr_tax_receipt`` The receipt itself: donor, year, invoice
|
||||
selection, stored amount, sequential number,
|
||||
draft/confirmed/sent/cancelled workflow,
|
||||
cancellation.
|
||||
``pdf_form_report`` PDF layout and rendering.
|
||||
``tax_receipt_in_portal`` Exposes a confirmed receipt to its donor: list,
|
||||
detail page with chatter, PDF download.
|
||||
=========================== ==================================================
|
||||
|
||||
This module computes no amount, assigns no number, selects no donation and
|
||||
lays out no PDF.
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Use the normal Odoo module installation procedure to install
|
||||
``tax_receipt_in_portal``. Dependencies (``portal``,
|
||||
``l10n_fr_tax_receipt`` and transitively ``pdf_form_report``) are
|
||||
installed automatically.
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Select the report rendering tax receipts in
|
||||
:menuselection:`Settings --> Invoicing --> Tax Receipts --> Tax Receipt
|
||||
Report`.
|
||||
|
||||
The setting is **per company**: each receipt is rendered with the report of
|
||||
its *issuing* company (``company_id`` of the receipt), not the current one —
|
||||
in the portal, the current company is the website's.
|
||||
|
||||
The selector only offers reports that:
|
||||
|
||||
* are of type ``PDF Form`` (``report_type = pdf_form``);
|
||||
* target the model ``l10n.fr.tax.receipt``;
|
||||
* belong to the company, or are shared (no company).
|
||||
|
||||
These conditions are checked again at rendering time, since a report can be
|
||||
edited after being selected. Deleting the selected report empties the
|
||||
setting.
|
||||
|
||||
Without a valid report, donors still see their receipts, but no download
|
||||
button: a discreet "PDF available soon" mention replaces it.
|
||||
|
||||
Bind the PDF fields to receipt field paths, for instance:
|
||||
|
||||
* ``name`` — receipt number;
|
||||
* ``year`` — civil year;
|
||||
* ``partner_id.name`` — donor;
|
||||
* ``amount_total`` — amount;
|
||||
* ``amount_total_words`` — amount in words;
|
||||
* ``last_payment_date`` — date of the last payment.
|
||||
|
||||
Prefer field paths over Python expressions: a field path is validated when
|
||||
saved, an expression is not.
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
Donors see only their own receipts, and only once **confirmed**. Confirming
|
||||
a receipt in the back office is therefore what publishes it.
|
||||
|
||||
A download button is shown only when the PDF can actually be rendered: a
|
||||
report is configured for the issuing company and no mapped field resolves
|
||||
to an empty value. Otherwise the button is replaced by a "PDF available
|
||||
soon" mention, and a direct call to the download URL redirects to the
|
||||
receipt page.
|
||||
|
||||
Security
|
||||
========
|
||||
|
||||
The model inherits ``portal.mixin``, so every receipt carries an
|
||||
``access_token`` used for share links and by the portal chatter plumbing.
|
||||
|
||||
Two independent access layers, both required:
|
||||
|
||||
* a **record rule** restricting portal users to receipts whose
|
||||
``partner_id`` is the connected user's commercial entity (or one of its
|
||||
children) and whose state is ``confirmed`` or ``sent``. This is what
|
||||
protects the detail and download routes for a logged-in donor.
|
||||
* an **explicit domain** in the controller, which also applies to internal
|
||||
users browsing ``/my`` — they are not subject to portal record rules.
|
||||
|
||||
A draft receipt is excluded by the record rule rather than by the
|
||||
controller: a receipt without a legal number must not leak through a route
|
||||
that someone forgets to filter.
|
||||
|
||||
A valid ``access_token`` grants access to a single receipt — and to its
|
||||
chatter — without login, for instance when the receipt is shared by
|
||||
email. Listing always requires a session.
|
||||
|
||||
The PDF rendering itself runs with elevated rights, after the access
|
||||
check, so that report field paths may reach data the donor cannot read
|
||||
directly.
|
||||
|
||||
Portal chatter
|
||||
==============
|
||||
|
||||
The detail page embeds the native portal chatter
|
||||
(``portal.message_thread``). A logged-in donor can read the thread and
|
||||
post messages (the composer posts as the donor; messages reach the
|
||||
back-office chatter of the receipt, so accountants see the
|
||||
conversation). Internal notes stay invisible to the donor, as handled by
|
||||
the core mail access rules.
|
||||
|
||||
For a public visitor using a share token, the thread is not readable:
|
||||
the core chatter endpoint checks record access, which a public user does
|
||||
not have. The section then renders empty; the token remains useful for
|
||||
the PDF and the receipt information.
|
||||
|
||||
Known issues / Roadmap
|
||||
======================
|
||||
|
||||
* **No archiving.** The PDF is rendered at each download and never stored.
|
||||
A receipt whose rendering becomes impossible (report deleted or
|
||||
reconfigured) can no longer be served, even though it was already sent.
|
||||
* **Availability is evaluated at each display.** Deciding whether to show
|
||||
the download button resolves every mapped PDF field of each displayed
|
||||
receipt — at most one page of the list. A report with costly Python
|
||||
expressions slows the list down accordingly.
|
||||
* **No email notification** and **no portal account invitation**: a donor
|
||||
without a portal account cannot reach the page. Both are deliberately
|
||||
out of scope.
|
||||
* **Coupling to a private API.** The blank-field guard calls
|
||||
``_compute_pdf_field_value`` from ``pdf_form_report``, which is private.
|
||||
``test_compute_api_contract`` fails loudly if that signature changes.
|
||||
The clean fix belongs upstream: ``pdf_form_report`` should expose a
|
||||
strict rendering mode that raises instead of logging a warning and
|
||||
leaving a PDF field blank.
|
||||
* Multi-company is handled through the receipt's own ``company_id``; no
|
||||
additional portal-side company filter is applied.
|
||||
|
||||
Why a blank-field guard
|
||||
=======================
|
||||
|
||||
``pdf_form_report`` deliberately swallows field resolution errors: a broken
|
||||
field path or Python expression only emits a log warning and leaves the PDF
|
||||
field blank. Serving a donor a tax receipt with a blank amount is worse
|
||||
than serving none, so every mapped field value is resolved before rendering
|
||||
and an empty one aborts with an explicit error.
|
||||
|
||||
Validation happens before rendering rather than by re-reading the produced
|
||||
PDF, because a report drawing its values as an overlay
|
||||
(``is_acroform = False``) exposes no readable form field afterwards.
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `our issues website
|
||||
<https://git.elabore.coop/elabore/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 Sainléger
|
||||
|
||||
Funders
|
||||
-------
|
||||
|
||||
The development of this module has been financially supported by:
|
||||
|
||||
* Elabore (https://elabore.coop)
|
||||
|
||||
Maintainer
|
||||
----------
|
||||
|
||||
This module is maintained by Elabore.
|
||||
2
tax_receipt_in_portal/__init__.py
Normal file
2
tax_receipt_in_portal/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
26
tax_receipt_in_portal/__manifest__.py
Normal file
26
tax_receipt_in_portal/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "tax_receipt_in_portal",
|
||||
"version": "18.0.1.0.0",
|
||||
"author": "Elabore",
|
||||
"maintainer": "Elabore",
|
||||
"website": "https://git.elabore.coop/elabore/account-tools",
|
||||
"license": "AGPL-3",
|
||||
"category": "Website",
|
||||
"summary": "Let donors download their tax receipts from the portal",
|
||||
"depends": [
|
||||
"portal",
|
||||
"l10n_fr_tax_receipt",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"security/ir_rule.xml",
|
||||
"views/portal_templates.xml",
|
||||
"views/res_config_settings_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
}
|
||||
1
tax_receipt_in_portal/controllers/__init__.py
Normal file
1
tax_receipt_in_portal/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import portal
|
||||
231
tax_receipt_in_portal/controllers/portal.py
Normal file
231
tax_receipt_in_portal/controllers/portal.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
"""Portal routes exposing a donor's own tax receipts."""
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
from odoo.http import content_disposition, request
|
||||
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.addons.portal.controllers.portal import pager as portal_pager
|
||||
|
||||
from ..models.l10n_fr_tax_receipt import PORTAL_VISIBLE_STATES
|
||||
|
||||
#: Technical name of the published model, kept in one place so the routes
|
||||
#: and the access check cannot drift apart.
|
||||
TAX_RECEIPT_MODEL = "l10n.fr.tax.receipt"
|
||||
|
||||
#: Session key storing the receipts most recently browsed, used by the
|
||||
#: portal to render prev/next links on the detail page.
|
||||
SESSION_HISTORY_KEY = "my_tax_receipts_history"
|
||||
|
||||
|
||||
class TaxReceiptCustomerPortal(CustomerPortal):
|
||||
"""Expose confirmed tax receipts to the donor they were issued to."""
|
||||
|
||||
def _get_tax_receipts_domain(self):
|
||||
"""Return the domain restricting receipts to the connected donor.
|
||||
|
||||
The record rule shipped with this module already enforces the same
|
||||
restriction for portal users, but an *internal* user browsing
|
||||
``/my`` is not subject to it. Repeating the filter here keeps the
|
||||
page honest for every kind of user.
|
||||
|
||||
Donations billed to a child contact are included, mirroring how
|
||||
Odoo scopes portal invoices to the commercial entity.
|
||||
|
||||
:return: an Odoo domain (list of tuples).
|
||||
"""
|
||||
commercial_partner = request.env.user.partner_id.commercial_partner_id
|
||||
return [
|
||||
("partner_id", "child_of", commercial_partner.id),
|
||||
("state", "in", list(PORTAL_VISIBLE_STATES)),
|
||||
]
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
"""Add the tax receipt counter to the portal home page.
|
||||
|
||||
:param counters: counter names requested by the portal home.
|
||||
:return: the portal home values, augmented with
|
||||
``tax_receipt_count`` when requested.
|
||||
"""
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
if "tax_receipt_count" in counters:
|
||||
receipts = request.env[TAX_RECEIPT_MODEL]
|
||||
values["tax_receipt_count"] = (
|
||||
receipts.search_count(self._get_tax_receipts_domain())
|
||||
if receipts.has_access("read")
|
||||
else 0
|
||||
)
|
||||
return values
|
||||
|
||||
def _tax_receipt_portal_invoices(self, receipt_sudo):
|
||||
"""Return the receipt invoices the current visitor may read.
|
||||
|
||||
Receipt invoices are created in the back office by accountants, so
|
||||
nothing guarantees the donor is allowed to read them: the portal
|
||||
rule of ``account.move`` requires a posted move the partner
|
||||
follows. Browsing the moves with the visitor's own environment and
|
||||
filtering on the effective access right keeps the detail page free
|
||||
of dead links.
|
||||
|
||||
:param receipt_sudo: ``l10n.fr.tax.receipt`` singleton, in sudo.
|
||||
:return: an ``account.move`` recordset, possibly empty, readable
|
||||
by the current user and restricted to posted moves.
|
||||
"""
|
||||
receipt_sudo.ensure_one()
|
||||
posted_invoices = receipt_sudo.invoice_ids.filtered(
|
||||
lambda move: move.state == "posted"
|
||||
)
|
||||
return (
|
||||
request.env["account.move"]
|
||||
.browse(posted_invoices.ids)
|
||||
._filtered_access("read")
|
||||
)
|
||||
|
||||
def _tax_receipt_get_page_view_values(self, receipt_sudo, access_token, **kwargs):
|
||||
"""Build the rendering values of the tax receipt detail page.
|
||||
|
||||
:param receipt_sudo: ``l10n.fr.tax.receipt`` singleton, in sudo.
|
||||
:param access_token: share token used to reach the page, or
|
||||
``None``.
|
||||
:param kwargs: extra values forwarded to ``_get_page_view_values``.
|
||||
:return: dict of QWeb values, including the prev/next record
|
||||
browsing keys computed by the portal from the session history.
|
||||
"""
|
||||
values = {
|
||||
"page_name": "tax_receipt",
|
||||
"tax_receipt": receipt_sudo,
|
||||
"invoices": self._tax_receipt_portal_invoices(receipt_sudo),
|
||||
"pdf_available": bool(receipt_sudo._get_pdf_available_receipts()),
|
||||
}
|
||||
return self._get_page_view_values(
|
||||
receipt_sudo,
|
||||
access_token,
|
||||
values,
|
||||
SESSION_HISTORY_KEY,
|
||||
False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/my/tax-receipts", "/my/tax-receipts/page/<int:page>"],
|
||||
type="http",
|
||||
auth="user",
|
||||
website=True,
|
||||
)
|
||||
def portal_my_tax_receipts(self, page=1, **kw):
|
||||
"""List the connected donor's tax receipts, most recent first.
|
||||
|
||||
:param page: 1-based page number for the pager.
|
||||
:return: the rendered portal list page.
|
||||
"""
|
||||
values = self._prepare_portal_layout_values()
|
||||
receipts = request.env[TAX_RECEIPT_MODEL]
|
||||
domain = self._get_tax_receipts_domain()
|
||||
total = receipts.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url="/my/tax-receipts",
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
)
|
||||
receipts_page = receipts.search(
|
||||
domain,
|
||||
limit=self._items_per_page,
|
||||
offset=pager["offset"],
|
||||
order="year desc, name desc",
|
||||
)
|
||||
request.session[SESSION_HISTORY_KEY] = receipts_page.ids[:100]
|
||||
values.update(
|
||||
{
|
||||
"tax_receipts": receipts_page,
|
||||
# Computed on the current page only: at most one page of
|
||||
# receipts is checked per request.
|
||||
"pdf_available_ids": set(
|
||||
receipts_page._get_pdf_available_receipts().ids
|
||||
),
|
||||
"page_name": "tax_receipt",
|
||||
"pager": pager,
|
||||
"default_url": "/my/tax-receipts",
|
||||
}
|
||||
)
|
||||
return request.render("tax_receipt_in_portal.portal_my_tax_receipts", values)
|
||||
|
||||
@http.route(
|
||||
["/my/tax-receipts/<int:receipt_id>"],
|
||||
type="http",
|
||||
auth="public",
|
||||
website=True,
|
||||
)
|
||||
def portal_my_tax_receipt_detail(self, receipt_id, access_token=None, **kw):
|
||||
"""Render one tax receipt, by ownership or by share token.
|
||||
|
||||
The page shows the receipt details, its invoices and the portal
|
||||
chatter, with a download link for the PDF.
|
||||
|
||||
:param receipt_id: database id of the receipt.
|
||||
:param access_token: share token allowing access without
|
||||
ownership.
|
||||
:param kw: extra query string arguments forwarded to the view
|
||||
values.
|
||||
:return: the rendered detail page, or a redirect to ``/my`` when
|
||||
the record is missing or not accessible.
|
||||
"""
|
||||
try:
|
||||
receipt_sudo = self._document_check_access(
|
||||
TAX_RECEIPT_MODEL, receipt_id, access_token
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect("/my")
|
||||
values = self._tax_receipt_get_page_view_values(
|
||||
receipt_sudo, access_token, **kw
|
||||
)
|
||||
return request.render("tax_receipt_in_portal.portal_tax_receipt_page", values)
|
||||
|
||||
@http.route(
|
||||
["/my/tax-receipts/<int:receipt_id>/download"],
|
||||
type="http",
|
||||
auth="public",
|
||||
website=True,
|
||||
)
|
||||
def portal_tax_receipt_download(self, receipt_id, access_token=None, **kw):
|
||||
"""Serve the PDF of one tax receipt belonging to the donor.
|
||||
|
||||
The PDF is rendered on demand; nothing is archived. Access is
|
||||
delegated to the standard portal check, which relies on the record
|
||||
rule or on the share token — so a draft receipt or another donor's
|
||||
receipt is refused.
|
||||
|
||||
When the PDF cannot be rendered (no report configured for the
|
||||
issuing company, or a blank mapped field), the visitor is sent back
|
||||
to the receipt page, which then shows no download button. This
|
||||
covers a configuration change between page display and click.
|
||||
|
||||
:param receipt_id: database id of the requested receipt.
|
||||
:param access_token: share token allowing access without
|
||||
ownership.
|
||||
:return: an ``application/pdf`` response, a redirect to the
|
||||
receipt page when no PDF is available, or a redirect to ``/my``
|
||||
when the receipt is not accessible.
|
||||
"""
|
||||
try:
|
||||
receipt_sudo = self._document_check_access(
|
||||
TAX_RECEIPT_MODEL, receipt_id, access_token
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect("/my")
|
||||
if not receipt_sudo._get_pdf_available_receipts():
|
||||
detail_url = f"/my/tax-receipts/{receipt_sudo.id}"
|
||||
if access_token:
|
||||
detail_url += "?" + url_encode({"access_token": access_token})
|
||||
return request.redirect(detail_url)
|
||||
pdf_bytes, filename = receipt_sudo._render_tax_receipt_pdf()
|
||||
headers = [
|
||||
("Content-Type", "application/pdf"),
|
||||
("Content-Length", len(pdf_bytes)),
|
||||
("Content-Disposition", content_disposition(filename)),
|
||||
]
|
||||
return request.make_response(pdf_bytes, headers=headers)
|
||||
251
tax_receipt_in_portal/i18n/fr.po
Normal file
251
tax_receipt_in_portal/i18n/fr.po
Normal file
@@ -0,0 +1,251 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * tax_receipt_in_portal
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-23 10:55+0000\n"
|
||||
"PO-Revision-Date: 2026-10-23 10:55+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: tax_receipt_in_portal
|
||||
#. odoo-python
|
||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
||||
msgid "(unnamed)"
|
||||
msgstr "(sans nom)"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||
msgid ""
|
||||
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
|
||||
" <span>PDF</span>"
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
|
||||
" <span>PDF</span>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid ""
|
||||
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
|
||||
" <span>Download PDF</span>"
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
|
||||
" <span>Télécharger le PDF</span>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Amount</strong>"
|
||||
msgstr "<strong>Montant</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Communication</strong>"
|
||||
msgstr "<strong>Communication</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Donor:</strong>"
|
||||
msgstr "<strong>Donateur :</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>In words</strong>"
|
||||
msgstr "<strong>En lettres</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Invoices</strong>"
|
||||
msgstr "<strong>Factures</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Last Payment Date</strong>"
|
||||
msgstr "<strong>Date du dernier versement</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Receipt #</strong>"
|
||||
msgstr "<strong>N° de reçu</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Status</strong>"
|
||||
msgstr "<strong>État</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "<strong>Year</strong>"
|
||||
msgstr "<strong>Année</strong>"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_warning
|
||||
msgid "Access warning"
|
||||
msgstr "Avertissement d'accès"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||
msgid "Amount"
|
||||
msgstr "Montant"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model,name:tax_receipt_in_portal.model_res_company
|
||||
msgid "Companies"
|
||||
msgstr "Sociétés"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model,name:tax_receipt_in_portal.model_res_config_settings
|
||||
msgid "Config Settings"
|
||||
msgstr "Paramètres de configuration"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model.fields,help:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url
|
||||
msgid "Customer Portal URL"
|
||||
msgstr "URL portail client"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "Invoice"
|
||||
msgstr "Facture"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "Invoice Date"
|
||||
msgstr "Date de facture"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#. odoo-python
|
||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
||||
msgid ""
|
||||
"No tax receipt report configured for company %(company)s. Select one in "
|
||||
"Settings > Invoicing > Tax Receipts."
|
||||
msgstr ""
|
||||
"Aucun rapport de reçu fiscal configuré pour la société %(company)s. Sélectionnez-en un dans Paramètres > Facturation > Reçus fiscaux."
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
|
||||
msgid "PDF Form report used to render tax receipts downloaded from the portal"
|
||||
msgstr ""
|
||||
"Rapport PDF Form utilisé pour générer les reçus fiscaux téléchargés depuis le portail"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model.fields,help:tax_receipt_in_portal.field_res_company__tax_receipt_report_id
|
||||
#: model:ir.model.fields,help:tax_receipt_in_portal.field_res_config_settings__tax_receipt_report_id
|
||||
msgid ""
|
||||
"PDF Form report used to render the tax receipts issued by this company in "
|
||||
"the portal. Without it, donors can see their receipts but not download them."
|
||||
msgstr ""
|
||||
"Rapport PDF Form utilisé pour générer dans le portail les reçus fiscaux émis par cette société. Sans lui, les donateurs voient leurs reçus mais ne peuvent pas les télécharger."
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "PDF available soon"
|
||||
msgstr "PDF bientôt disponible"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url
|
||||
msgid "Portal Access URL"
|
||||
msgstr "URL d'accès portail"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||
msgid "Receipt #"
|
||||
msgstr "N° de reçu"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#. odoo-python
|
||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
||||
msgid ""
|
||||
"Report %(name)s is of type %(actual)s, but tax receipts require a "
|
||||
"%(expected)s report."
|
||||
msgstr ""
|
||||
"Le rapport %(name)s est de type %(actual)s, alors que les reçus fiscaux "
|
||||
"nécessitent un rapport %(expected)s."
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#. odoo-python
|
||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
||||
msgid ""
|
||||
"Report %(name)s targets model %(actual)s, but tax receipts require a report "
|
||||
"on %(expected)s."
|
||||
msgstr ""
|
||||
"Le rapport %(name)s cible le modèle %(actual)s, alors que les reçus fiscaux "
|
||||
"nécessitent un rapport sur %(expected)s."
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_token
|
||||
msgid "Security Token"
|
||||
msgstr "Jeton de sécurité"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "Tax Receipt -"
|
||||
msgstr "Reçu fiscal -"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_res_company__tax_receipt_report_id
|
||||
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_res_config_settings__tax_receipt_report_id
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
|
||||
msgid "Tax Receipt Report"
|
||||
msgstr "Rapport de reçu fiscal"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_breadcrumbs_tax_receipt
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_home_tax_receipt
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
|
||||
msgid "Tax Receipts"
|
||||
msgstr "Reçus fiscaux"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model:ir.model,name:tax_receipt_in_portal.model_l10n_fr_tax_receipt
|
||||
msgid "Tax receipt"
|
||||
msgstr "Reçu fiscal"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#. odoo-python
|
||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
||||
msgid ""
|
||||
"Tax receipt %(receipt)s cannot be rendered: the PDF field(s) %(fields)s "
|
||||
"resolve to an empty value. Check the field mapping of report %(report)s."
|
||||
msgstr ""
|
||||
"Le reçu fiscal %(receipt)s ne peut pas être généré : le ou les champs PDF "
|
||||
"%(fields)s donnent une valeur vide. Vérifiez le mappage des champs du "
|
||||
"rapport %(report)s."
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#. odoo-python
|
||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
||||
msgid ""
|
||||
"Tax receipt %(receipt)s is in state %(state)s and cannot be rendered. Only "
|
||||
"%(allowed)s receipts can."
|
||||
msgstr ""
|
||||
"Le reçu fiscal %(receipt)s est à l'état %(state)s et ne peut pas être "
|
||||
"généré. Seuls les reçus %(allowed)s peuvent l'être."
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||
msgid "There is currently no tax receipt for your account."
|
||||
msgstr "Aucun reçu fiscal n'est actuellement disponible pour votre compte."
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||
msgid "Total"
|
||||
msgstr "Total"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_home_tax_receipt
|
||||
msgid "View and download your yearly donation receipts"
|
||||
msgstr "Consultez et téléchargez vos reçus fiscaux annuels"
|
||||
|
||||
#. module: tax_receipt_in_portal
|
||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||
msgid "Year"
|
||||
msgstr "Année"
|
||||
3
tax_receipt_in_portal/models/__init__.py
Normal file
3
tax_receipt_in_portal/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import l10n_fr_tax_receipt
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
216
tax_receipt_in_portal/models/l10n_fr_tax_receipt.py
Normal file
216
tax_receipt_in_portal/models/l10n_fr_tax_receipt.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
"""Publish tax receipts to the donor portal.
|
||||
|
||||
This module owns the *publication* of a tax receipt, not its content:
|
||||
amounts, numbering and the invoice selection belong to
|
||||
``l10n_fr_tax_receipt``, and the PDF layout belongs to ``pdf_form_report``.
|
||||
"""
|
||||
|
||||
from odoo import _, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
#: Receipt states a donor is allowed to see and download. A draft receipt
|
||||
#: carries no legal number yet and must never reach the portal.
|
||||
PORTAL_VISIBLE_STATES = ("confirmed", "sent")
|
||||
|
||||
#: ``report_type`` registered by ``pdf_form_report``.
|
||||
PDF_FORM_REPORT_TYPE = "pdf_form"
|
||||
|
||||
|
||||
class L10nFrTaxReceipt(models.Model):
|
||||
"""Turn tax receipts into shareable portal documents.
|
||||
|
||||
``l10n_fr_tax_receipt`` declares the receipt as a mail thread only.
|
||||
Adding ``portal.mixin`` brings in ``access_token`` / ``access_url``,
|
||||
which the portal controllers need for ownership checks and
|
||||
token-based sharing, and which feeds the portal chatter.
|
||||
"""
|
||||
|
||||
_name = "l10n.fr.tax.receipt"
|
||||
_inherit = ["l10n.fr.tax.receipt", "portal.mixin"]
|
||||
|
||||
def _compute_access_url(self):
|
||||
"""Point each receipt at its portal detail page.
|
||||
|
||||
``portal.mixin`` computes ``access_url`` as ``'#'``; this override
|
||||
makes ``get_portal_url()`` and ``_get_share_url()`` resolve to the
|
||||
route served by
|
||||
``TaxReceiptCustomerPortal.portal_my_tax_receipt_detail``.
|
||||
|
||||
:return: whatever ``portal.mixin`` returns, i.e. ``None``. The
|
||||
method assigns ``access_url`` on every record of ``self``.
|
||||
"""
|
||||
result = super()._compute_access_url()
|
||||
for receipt in self:
|
||||
receipt.access_url = f"/my/tax-receipts/{receipt.id}"
|
||||
return result
|
||||
|
||||
def _get_tax_receipt_report(self):
|
||||
"""Return the ``pdf_form`` report rendering this receipt.
|
||||
|
||||
The report is the one selected on the receipt's **issuing**
|
||||
company (Settings > Invoicing > Tax Receipts), not on the current
|
||||
company: in the portal, the current company is the website's,
|
||||
which may differ.
|
||||
|
||||
The type and target model are checked again here although the
|
||||
settings field restricts the choice: the report can be edited
|
||||
after being selected.
|
||||
|
||||
:return: a singleton ``ir.actions.report`` recordset, sudoed.
|
||||
:raises UserError: if the company has no report configured, if the
|
||||
report is not a ``pdf_form`` report, or if it does not target
|
||||
``l10n.fr.tax.receipt``.
|
||||
"""
|
||||
self.ensure_one()
|
||||
company = self.sudo().company_id
|
||||
report = company.tax_receipt_report_id
|
||||
if not report:
|
||||
raise UserError(
|
||||
_(
|
||||
"No tax receipt report configured for company "
|
||||
"%(company)s. Select one in Settings > Invoicing > Tax "
|
||||
"Receipts.",
|
||||
company=company.name,
|
||||
)
|
||||
)
|
||||
if report.report_type != PDF_FORM_REPORT_TYPE:
|
||||
raise UserError(
|
||||
_(
|
||||
"Report %(name)s is of type %(actual)s, but tax receipts "
|
||||
"require a %(expected)s report.",
|
||||
name=report.name,
|
||||
actual=report.report_type,
|
||||
expected=PDF_FORM_REPORT_TYPE,
|
||||
)
|
||||
)
|
||||
if report.model != self._name:
|
||||
raise UserError(
|
||||
_(
|
||||
"Report %(name)s targets model %(actual)s, but tax "
|
||||
"receipts require a report on %(expected)s.",
|
||||
name=report.name,
|
||||
actual=report.model,
|
||||
expected=self._name,
|
||||
)
|
||||
)
|
||||
return report
|
||||
|
||||
def _check_report_renderable(self, report):
|
||||
"""Fail if a PDF field of ``report`` resolves to an empty value.
|
||||
|
||||
``pdf_form_report`` deliberately swallows field resolution errors:
|
||||
a broken field path or Python expression only emits a log warning
|
||||
and leaves the PDF field blank. Serving a tax receipt with a blank
|
||||
amount is worse than serving no receipt at all, so the field values
|
||||
are resolved up-front and an empty one aborts the rendering.
|
||||
|
||||
Validation happens before rendering rather than by re-reading the
|
||||
produced PDF, because a report drawing its values as an overlay
|
||||
(``is_acroform = False``) exposes no readable form field afterwards.
|
||||
|
||||
Checkbox lines are skipped: an unchecked box is a meaningful
|
||||
``False``. Lines set to "leave empty" are skipped as intentional.
|
||||
|
||||
:param report: singleton ``ir.actions.report`` of type ``pdf_form``.
|
||||
:raises UserError: if at least one field resolves to ``None`` or to
|
||||
a blank string.
|
||||
"""
|
||||
self.ensure_one()
|
||||
report.ensure_one()
|
||||
blank_fields = []
|
||||
for line in report.form_fields_match:
|
||||
if line.evaluation_type == "empty":
|
||||
continue
|
||||
if line.pdf_field_type == "checkbox":
|
||||
continue
|
||||
value = report._compute_pdf_field_value(line, self)
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
blank_fields.append(line.pdf_field_name or _("(unnamed)"))
|
||||
if blank_fields:
|
||||
raise UserError(
|
||||
_(
|
||||
"Tax receipt %(receipt)s cannot be rendered: the PDF "
|
||||
"field(s) %(fields)s resolve to an empty value. Check "
|
||||
"the field mapping of report %(report)s.",
|
||||
receipt=self.name,
|
||||
fields=", ".join(blank_fields),
|
||||
report=report.name,
|
||||
)
|
||||
)
|
||||
|
||||
def _get_pdf_available_receipts(self):
|
||||
"""Return the receipts of ``self`` whose PDF can be rendered.
|
||||
|
||||
Applies exactly the checks of ``_render_tax_receipt_pdf`` — visible
|
||||
state, configured and valid report, no blank field — but as a
|
||||
filter instead of raising, so the portal only offers a download
|
||||
button that works.
|
||||
|
||||
The checks run in sudo, like the rendering itself: evaluated with
|
||||
the donor's rights, a field path reaching data they cannot read
|
||||
would resolve to nothing and hide a downloadable receipt.
|
||||
|
||||
The report is resolved once per issuing company.
|
||||
|
||||
:return: a subset of ``self``, in the caller's environment.
|
||||
"""
|
||||
reports_by_company = {}
|
||||
available_ids = []
|
||||
for receipt in self.sudo():
|
||||
if receipt.state not in PORTAL_VISIBLE_STATES:
|
||||
continue
|
||||
company = receipt.company_id
|
||||
if company not in reports_by_company:
|
||||
try:
|
||||
reports_by_company[company] = receipt._get_tax_receipt_report()
|
||||
except UserError:
|
||||
reports_by_company[company] = None
|
||||
report = reports_by_company[company]
|
||||
if not report:
|
||||
continue
|
||||
try:
|
||||
receipt._check_report_renderable(report)
|
||||
except UserError:
|
||||
continue
|
||||
available_ids.append(receipt.id)
|
||||
return self.browse(available_ids)
|
||||
|
||||
def _render_tax_receipt_pdf(self):
|
||||
"""Render this tax receipt as a PDF through ``pdf_form_report``.
|
||||
|
||||
Nothing is stored: the PDF is rendered on demand. This is sound
|
||||
because a confirmed receipt is frozen by ``l10n_fr_tax_receipt``
|
||||
(its invoice set can no longer change and its amount is stored), so
|
||||
rendering the same receipt twice yields the same document.
|
||||
|
||||
Access control is the **caller's** responsibility: the rendering
|
||||
itself runs with elevated rights so that the report field paths may
|
||||
reach data the donor cannot read directly.
|
||||
|
||||
:return: a ``(pdf_bytes, filename)`` tuple.
|
||||
:raises UserError: if the receipt is not visible to donors yet, if
|
||||
the report is misconfigured, or if a PDF field is blank.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state not in PORTAL_VISIBLE_STATES:
|
||||
raise UserError(
|
||||
_(
|
||||
"Tax receipt %(receipt)s is in state %(state)s and "
|
||||
"cannot be rendered. Only %(allowed)s receipts can.",
|
||||
receipt=self.name,
|
||||
state=self.state,
|
||||
allowed=", ".join(PORTAL_VISIBLE_STATES),
|
||||
)
|
||||
)
|
||||
report = self._get_tax_receipt_report()
|
||||
self._check_report_renderable(report)
|
||||
# One receipt per call: the multi-record branch of
|
||||
# _render_pdf_form rebuilds a fresh PdfWriter and loses the
|
||||
# document-level /AcroForm catalog, so field values may vanish.
|
||||
pdf_bytes, _extension = (
|
||||
self.env["ir.actions.report"].sudo()._render(report, [self.id])
|
||||
)
|
||||
filename = report._compute_pdf_form_filename([self.id])
|
||||
return pdf_bytes, filename
|
||||
29
tax_receipt_in_portal/models/res_company.py
Normal file
29
tax_receipt_in_portal/models/res_company.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
"""Per-company choice of the report rendering tax receipts."""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
#: Domain restricting the selectable reports to PDF Form reports on tax
|
||||
#: receipts. Shared by the company field and its settings counterpart.
|
||||
TAX_RECEIPT_REPORT_DOMAIN = (
|
||||
"[('report_type', '=', 'pdf_form'),"
|
||||
" ('model', '=', 'l10n.fr.tax.receipt'),"
|
||||
" ('company_id', 'in', [False, {company}])]"
|
||||
)
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
"""Store the tax receipt report of each issuing company."""
|
||||
|
||||
_inherit = "res.company"
|
||||
|
||||
tax_receipt_report_id = fields.Many2one(
|
||||
"ir.actions.report",
|
||||
string="Tax Receipt Report",
|
||||
ondelete="set null",
|
||||
domain=TAX_RECEIPT_REPORT_DOMAIN.format(company="id"),
|
||||
help="PDF Form report used to render the tax receipts issued by "
|
||||
"this company in the portal. Without it, donors can see their "
|
||||
"receipts but not download them.",
|
||||
)
|
||||
19
tax_receipt_in_portal/models/res_config_settings.py
Normal file
19
tax_receipt_in_portal/models/res_config_settings.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
"""Expose the tax receipt report in the Invoicing settings."""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
from .res_company import TAX_RECEIPT_REPORT_DOMAIN
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
"""Add the tax receipt report selector to the settings."""
|
||||
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
tax_receipt_report_id = fields.Many2one(
|
||||
related="company_id.tax_receipt_report_id",
|
||||
readonly=False,
|
||||
domain=TAX_RECEIPT_REPORT_DOMAIN.format(company="company_id"),
|
||||
)
|
||||
2
tax_receipt_in_portal/security/ir.model.access.csv
Normal file
2
tax_receipt_in_portal/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_l10n_fr_tax_receipt_portal,l10n.fr.tax.receipt portal read-only,l10n_fr_tax_receipt.model_l10n_fr_tax_receipt,base.group_portal,1,0,0,0
|
||||
|
29
tax_receipt_in_portal/security/ir_rule.xml
Normal file
29
tax_receipt_in_portal/security/ir_rule.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!--
|
||||
A donor may only read the receipts issued to their own commercial
|
||||
entity, and only once confirmed: a draft receipt carries no legal
|
||||
number yet.
|
||||
|
||||
Odoo scopes portal invoices through `message_partner_ids`, i.e. on
|
||||
followers. That is deliberately not used here: tax receipts created
|
||||
by the yearly generation wizard have no follower, so such a rule
|
||||
would always yield an empty list.
|
||||
-->
|
||||
<record id="tax_receipt_rule_portal" model="ir.rule">
|
||||
<field name="name">Portal: own confirmed tax receipts</field>
|
||||
<field
|
||||
name="model_id"
|
||||
ref="l10n_fr_tax_receipt.model_l10n_fr_tax_receipt"
|
||||
/>
|
||||
<field name="domain_force">
|
||||
[('partner_id', 'child_of', [user.commercial_partner_id.id]),
|
||||
('state', 'in', ['confirmed', 'sent'])]
|
||||
</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]" />
|
||||
<field name="perm_read" eval="True" />
|
||||
<field name="perm_write" eval="False" />
|
||||
<field name="perm_create" eval="False" />
|
||||
<field name="perm_unlink" eval="False" />
|
||||
</record>
|
||||
</odoo>
|
||||
1
tax_receipt_in_portal/tests/__init__.py
Normal file
1
tax_receipt_in_portal/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_portal_tax_receipt
|
||||
585
tax_receipt_in_portal/tests/test_portal_tax_receipt.py
Normal file
585
tax_receipt_in_portal/tests/test_portal_tax_receipt.py
Normal file
@@ -0,0 +1,585 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
"""Tests for the donor tax receipt portal."""
|
||||
|
||||
import base64
|
||||
import inspect
|
||||
import io
|
||||
import re
|
||||
|
||||
from odoo import Command
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tools import mute_logger
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
|
||||
|
||||
#: Logger of the third-party renderer, which downgrades field resolution
|
||||
#: errors to warnings. Muted where a test triggers one on purpose.
|
||||
PDF_FORM_LOGGER = "odoo.addons.pdf_form_report.models.ir_actions_report"
|
||||
|
||||
|
||||
def _make_tax_receipt_pdf():
|
||||
"""Build a minimal AcroForm PDF with the receipt fields.
|
||||
|
||||
:return: the PDF as bytes, carrying the text fields ``NUMBER``,
|
||||
``DONOR`` and ``AMOUNT``.
|
||||
"""
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
|
||||
buf = io.BytesIO()
|
||||
canvas = Canvas(buf)
|
||||
canvas.setFont("Helvetica", 12)
|
||||
canvas.drawString(100, 780, "Tax receipt")
|
||||
form = canvas.acroForm
|
||||
for index, field_name in enumerate(("NUMBER", "DONOR", "AMOUNT")):
|
||||
form.textfield(
|
||||
name=field_name,
|
||||
tooltip=field_name,
|
||||
x=100,
|
||||
y=700 - index * 40,
|
||||
width=300,
|
||||
height=20,
|
||||
value="",
|
||||
maxlen=200,
|
||||
)
|
||||
canvas.showPage()
|
||||
canvas.save()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
|
||||
"""Cover portal visibility, isolation and PDF rendering."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Build a pdf_form report, two donors and their receipts."""
|
||||
super().setUpClass()
|
||||
cls.report = cls._create_pdf_form_report("l10n.fr.tax.receipt")
|
||||
cls._map_field("NUMBER", "name")
|
||||
cls._map_field("DONOR", "partner_id.name")
|
||||
cls._map_field("AMOUNT", "amount_total_words")
|
||||
cls.env.company.tax_receipt_report_id = cls.report
|
||||
|
||||
cls.donor_a, cls.user_a = cls._create_donor("donor_a")
|
||||
cls.donor_b, cls.user_b = cls._create_donor("donor_b")
|
||||
cls.receipt_a = cls._create_receipt(cls.donor_a, 2025, 120.0)
|
||||
cls.receipt_a.action_confirm()
|
||||
cls.receipt_b = cls._create_receipt(cls.donor_b, 2025, 90.0)
|
||||
cls.receipt_b.action_confirm()
|
||||
# Left in draft on purpose: it must stay invisible to the donor.
|
||||
cls.receipt_draft = cls._create_receipt(cls.donor_a, 2024, 60.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _create_pdf_form_report(cls, model_name, suffix=""):
|
||||
"""Create a ``pdf_form`` report on ``model_name``.
|
||||
|
||||
:param model_name: technical name of the report target model.
|
||||
:param suffix: distinguishes several reports on the same model,
|
||||
whose name and ``report_name`` must stay unique.
|
||||
:return: the created ``ir.actions.report`` record.
|
||||
"""
|
||||
report = cls.env["ir.actions.report"].create(
|
||||
{
|
||||
"name": f"Tax receipt test ({model_name}){suffix}",
|
||||
"model": model_name,
|
||||
"report_name": f"tax_receipt_in_portal.test_{model_name}{suffix}",
|
||||
"report_type": "pdf_form",
|
||||
"filename_pattern": "tax_receipt_{object.id}.pdf",
|
||||
}
|
||||
)
|
||||
report._set_pdf_template(
|
||||
base64.b64encode(_make_tax_receipt_pdf()), filename="template.pdf"
|
||||
)
|
||||
return report
|
||||
|
||||
@classmethod
|
||||
def _map_field(cls, pdf_field_name, field_path):
|
||||
"""Bind a PDF AcroForm field to a receipt field path.
|
||||
|
||||
:param pdf_field_name: name of the AcroForm text field.
|
||||
:param field_path: dotted field path on the report target model.
|
||||
:return: the created ``pdf.form.field.line`` record.
|
||||
"""
|
||||
return cls.env["pdf.form.field.line"].create(
|
||||
{
|
||||
"report_id": cls.report.id,
|
||||
"pdf_field_name": pdf_field_name,
|
||||
"pdf_field_type": "text",
|
||||
"is_acroform": True,
|
||||
"page": 1,
|
||||
"evaluation_type": "field",
|
||||
"field_path": field_path,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _create_donor(cls, login):
|
||||
"""Create a partner and its portal user.
|
||||
|
||||
:param login: login, password and partner name of the donor.
|
||||
:return: a ``(partner, user)`` tuple.
|
||||
"""
|
||||
partner = cls.env["res.partner"].create({"name": login})
|
||||
user = cls.env["res.users"].create(
|
||||
{
|
||||
"name": login,
|
||||
"login": login,
|
||||
"password": login,
|
||||
"partner_id": partner.id,
|
||||
"groups_id": [Command.set([cls.env.ref("base.group_portal").id])],
|
||||
}
|
||||
)
|
||||
return partner, user
|
||||
|
||||
@classmethod
|
||||
def _create_receipt(cls, partner, year, amount):
|
||||
"""Create a draft receipt backed by one posted invoice.
|
||||
|
||||
:param partner: donor the invoice and receipt belong to.
|
||||
:param year: civil year of the receipt.
|
||||
:param amount: untaxed amount of the donation.
|
||||
:return: the created ``l10n.fr.tax.receipt`` record.
|
||||
"""
|
||||
move = cls.env["account.move"].create(
|
||||
{
|
||||
"move_type": "out_invoice",
|
||||
"partner_id": partner.id,
|
||||
"invoice_date": f"{year}-06-15",
|
||||
"journal_id": cls.company_data["default_journal_sale"].id,
|
||||
"tax_receipt_eligible": True,
|
||||
"invoice_line_ids": [
|
||||
Command.create(
|
||||
{
|
||||
"name": "Donation",
|
||||
"quantity": 1,
|
||||
"price_unit": amount,
|
||||
"tax_ids": [],
|
||||
}
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
move.action_post()
|
||||
return cls.env["l10n.fr.tax.receipt"].create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"company_id": cls.env.company.id,
|
||||
"year": year,
|
||||
"invoice_ids": [Command.set(move.ids)],
|
||||
}
|
||||
)
|
||||
|
||||
def _login(self, user):
|
||||
"""Authenticate the HTTP session as ``user``.
|
||||
|
||||
:param user: ``res.users`` record whose login equals its password.
|
||||
"""
|
||||
self.authenticate(user.login, user.login)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Portal access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_download_confirmed(self):
|
||||
"""A donor downloads their own receipt, filled with their data."""
|
||||
import pypdf
|
||||
|
||||
self._login(self.user_a)
|
||||
response = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}/download")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], "application/pdf")
|
||||
self.assertTrue(response.content.startswith(b"%PDF"))
|
||||
|
||||
fields = pypdf.PdfReader(io.BytesIO(response.content)).get_fields()
|
||||
self.assertEqual(fields["NUMBER"]["/V"], self.receipt_a.name)
|
||||
self.assertEqual(fields["DONOR"]["/V"], self.donor_a.name)
|
||||
self.assertEqual(fields["AMOUNT"]["/V"], self.receipt_a.amount_total_words)
|
||||
|
||||
def test_detail_page(self):
|
||||
"""A donor sees their receipt details, PDF link, chatter, invoices.
|
||||
|
||||
The invoice is readable: Odoo auto-subscribes partners linked to a
|
||||
user when the invoice is created, which satisfies the
|
||||
``account.move`` portal rule.
|
||||
"""
|
||||
invoice = self.receipt_a.invoice_ids
|
||||
self._login(self.user_a)
|
||||
page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}")
|
||||
|
||||
self.assertEqual(page.status_code, 200)
|
||||
# Receipt identity and key information.
|
||||
self.assertIn(self.receipt_a.name, page.text)
|
||||
self.assertIn(str(self.receipt_a.year), page.text)
|
||||
# Download link for the PDF.
|
||||
self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", page.text)
|
||||
# Portal chatter anchored on this receipt.
|
||||
self.assertIn('id="discussion"', page.text)
|
||||
self.assertIn(f'data-res_id="{self.receipt_a.id}"', page.text)
|
||||
self.assertIn('data-res_model="l10n.fr.tax.receipt"', page.text)
|
||||
# Readable invoice, linked to its own portal page.
|
||||
self.assertIn(f"/my/invoices/{invoice.id}", page.text)
|
||||
self.assertIn(invoice.name, page.text)
|
||||
|
||||
def test_detail_page_without_readable_invoice(self):
|
||||
"""Invoices the donor cannot read are hidden, not linked.
|
||||
|
||||
The ``account.move`` portal rule requires the partner to follow the
|
||||
invoice; a donor unsubscribed from it (e.g. an invoice never
|
||||
emailed) must not get dead links.
|
||||
"""
|
||||
invoice = self.receipt_a.invoice_ids
|
||||
invoice.message_unsubscribe(partner_ids=self.donor_a.ids)
|
||||
|
||||
self._login(self.user_a)
|
||||
page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}")
|
||||
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertNotIn(f"/my/invoices/{invoice.id}", page.text)
|
||||
|
||||
def test_detail_page_chatter_data(self):
|
||||
"""The chatter data endpoint grants the donor read access.
|
||||
|
||||
This is what the OWL portal chatter calls under the hood: if the
|
||||
donor cannot read the thread through it, the communication section
|
||||
is an empty shell.
|
||||
"""
|
||||
self._login(self.user_a)
|
||||
result = self.make_jsonrpc_request(
|
||||
"/mail/thread/data",
|
||||
{
|
||||
"thread_model": "l10n.fr.tax.receipt",
|
||||
"thread_id": self.receipt_a.id,
|
||||
"request_list": ["messages"],
|
||||
},
|
||||
)
|
||||
thread = next(
|
||||
thread
|
||||
for thread in result["mail.thread"]
|
||||
if thread["id"] == self.receipt_a.id
|
||||
)
|
||||
self.assertTrue(thread["hasReadAccess"])
|
||||
self.assertFalse(thread["hasWriteAccess"])
|
||||
|
||||
def test_detail_page_chatter_data_isolation(self):
|
||||
"""The chatter data endpoint refuses another donor's receipt."""
|
||||
self._login(self.user_b)
|
||||
result = self.make_jsonrpc_request(
|
||||
"/mail/thread/data",
|
||||
{
|
||||
"thread_model": "l10n.fr.tax.receipt",
|
||||
"thread_id": self.receipt_a.id,
|
||||
"request_list": ["messages"],
|
||||
},
|
||||
)
|
||||
thread = next(
|
||||
thread
|
||||
for thread in result["mail.thread"]
|
||||
if thread["id"] == self.receipt_a.id
|
||||
)
|
||||
self.assertFalse(thread["hasReadAccess"])
|
||||
|
||||
def test_portal_isolation(self):
|
||||
"""A donor can neither list nor open another donor's receipt."""
|
||||
self._login(self.user_a)
|
||||
|
||||
listing = self.url_open("/my/tax-receipts")
|
||||
self.assertEqual(listing.status_code, 200)
|
||||
self.assertIn(self.receipt_a.name, listing.text)
|
||||
self.assertNotIn(self.receipt_b.name, listing.text)
|
||||
|
||||
for url in (
|
||||
f"/my/tax-receipts/{self.receipt_b.id}",
|
||||
f"/my/tax-receipts/{self.receipt_b.id}/download",
|
||||
):
|
||||
denied = self.url_open(url, allow_redirects=False)
|
||||
self.assertIn(denied.status_code, (302, 303))
|
||||
self.assertTrue(denied.headers["Location"].endswith("/my"))
|
||||
|
||||
def test_draft_not_exposed(self):
|
||||
"""A draft receipt is absent from the list, page and download."""
|
||||
self._login(self.user_a)
|
||||
|
||||
listing = self.url_open("/my/tax-receipts")
|
||||
# One receipt visible, two links each: the detail page and the PDF.
|
||||
self.assertEqual(
|
||||
listing.text.count("/my/tax-receipts/"),
|
||||
2,
|
||||
"only the confirmed receipt should be listed",
|
||||
)
|
||||
self.assertNotIn(f"/my/tax-receipts/{self.receipt_draft.id}", listing.text)
|
||||
|
||||
for url in (
|
||||
f"/my/tax-receipts/{self.receipt_draft.id}",
|
||||
f"/my/tax-receipts/{self.receipt_draft.id}/download",
|
||||
):
|
||||
denied = self.url_open(url, allow_redirects=False)
|
||||
self.assertIn(denied.status_code, (302, 303))
|
||||
|
||||
def test_home_counter(self):
|
||||
"""The portal home counter reports only visible receipts."""
|
||||
self._login(self.user_a)
|
||||
result = self.make_jsonrpc_request(
|
||||
"/my/counters", {"counters": ["tax_receipt_count"]}
|
||||
)
|
||||
self.assertEqual(result["tax_receipt_count"], 1)
|
||||
|
||||
def test_empty_state(self):
|
||||
"""A donor without any receipt gets the empty page, not an error."""
|
||||
_partner, user = self._create_donor("donor_without_receipt")
|
||||
self._login(user)
|
||||
|
||||
# Asserted on the absence of download links rather than on the
|
||||
# empty-state wording, which is translatable.
|
||||
listing = self.url_open("/my/tax-receipts")
|
||||
self.assertEqual(listing.status_code, 200)
|
||||
self.assertNotIn("/my/tax-receipts/", listing.text)
|
||||
|
||||
result = self.make_jsonrpc_request(
|
||||
"/my/counters", {"counters": ["tax_receipt_count"]}
|
||||
)
|
||||
self.assertEqual(result["tax_receipt_count"], 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PDF availability
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
#: CSS class of the "PDF available soon" mention, used instead of the
|
||||
#: translatable wording.
|
||||
PENDING_CLASS = "o_tax_receipt_pdf_pending"
|
||||
|
||||
def test_no_report_hides_download_buttons(self):
|
||||
"""Without a configured report, no download button is shown.
|
||||
|
||||
The receipt itself stays visible: only its PDF is unavailable.
|
||||
"""
|
||||
self.env.company.tax_receipt_report_id = False
|
||||
self._login(self.user_a)
|
||||
|
||||
listing = self.url_open("/my/tax-receipts")
|
||||
self.assertEqual(listing.status_code, 200)
|
||||
self.assertIn(self.receipt_a.name, listing.text)
|
||||
self.assertNotIn("/download", listing.text)
|
||||
self.assertIn(self.PENDING_CLASS, listing.text)
|
||||
|
||||
page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}")
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertNotIn("/download", page.text)
|
||||
self.assertIn(self.PENDING_CLASS, page.text)
|
||||
|
||||
def test_available_pdf_shows_buttons(self):
|
||||
"""With a valid report, both buttons are shown, no pending mention."""
|
||||
self._login(self.user_a)
|
||||
|
||||
listing = self.url_open("/my/tax-receipts")
|
||||
self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", listing.text)
|
||||
self.assertNotIn(self.PENDING_CLASS, listing.text)
|
||||
|
||||
page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}")
|
||||
self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", page.text)
|
||||
self.assertNotIn(self.PENDING_CLASS, page.text)
|
||||
|
||||
@mute_logger(PDF_FORM_LOGGER)
|
||||
def test_blank_field_hides_button_for_that_receipt_only(self):
|
||||
"""A receipt with a blank mapped field loses its button, not others."""
|
||||
older_receipt = self._create_receipt(self.donor_a, 2023, 50.0)
|
||||
older_receipt.action_confirm()
|
||||
self.env["pdf.form.field.line"].create(
|
||||
{
|
||||
"report_id": self.report.id,
|
||||
"pdf_field_name": "ONLY_2025",
|
||||
"pdf_field_type": "text",
|
||||
"is_acroform": False,
|
||||
"page": 1,
|
||||
"evaluation_type": "equation",
|
||||
"python_expr": "'ok' if object.year == 2025 else ''",
|
||||
}
|
||||
)
|
||||
self._login(self.user_a)
|
||||
|
||||
listing = self.url_open("/my/tax-receipts")
|
||||
self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", listing.text)
|
||||
self.assertNotIn(f"/my/tax-receipts/{older_receipt.id}/download", listing.text)
|
||||
self.assertEqual(listing.text.count(self.PENDING_CLASS), 1)
|
||||
|
||||
def test_download_unavailable_redirects_to_receipt(self):
|
||||
"""Downloading an unavailable PDF goes back to the receipt page."""
|
||||
self.env.company.tax_receipt_report_id = False
|
||||
self._login(self.user_a)
|
||||
|
||||
response = self.url_open(
|
||||
f"/my/tax-receipts/{self.receipt_a.id}/download",
|
||||
allow_redirects=False,
|
||||
)
|
||||
self.assertIn(response.status_code, (302, 303))
|
||||
self.assertTrue(
|
||||
response.headers["Location"].endswith(
|
||||
f"/my/tax-receipts/{self.receipt_a.id}"
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering safety net
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@mute_logger(PDF_FORM_LOGGER)
|
||||
def test_blank_field_guard(self):
|
||||
"""A field resolving to nothing aborts instead of blanking the PDF."""
|
||||
self.env["pdf.form.field.line"].create(
|
||||
{
|
||||
"report_id": self.report.id,
|
||||
"pdf_field_name": "BROKEN",
|
||||
"pdf_field_type": "text",
|
||||
"is_acroform": False,
|
||||
"page": 1,
|
||||
"evaluation_type": "equation",
|
||||
"python_expr": "object.this_field_does_not_exist",
|
||||
}
|
||||
)
|
||||
# The message must name the offending field, otherwise this test
|
||||
# would also pass for an unrelated UserError.
|
||||
with self.assertRaisesRegex(UserError, "BROKEN"):
|
||||
self.receipt_a._render_tax_receipt_pdf()
|
||||
|
||||
@mute_logger(PDF_FORM_LOGGER)
|
||||
def test_unchecked_checkbox_is_not_blank(self):
|
||||
"""An unchecked checkbox is a legitimate value, not a blank field."""
|
||||
self.env["pdf.form.field.line"].create(
|
||||
{
|
||||
"report_id": self.report.id,
|
||||
"pdf_field_name": "UNCHECKED",
|
||||
"pdf_field_type": "checkbox",
|
||||
"is_acroform": False,
|
||||
"page": 1,
|
||||
"evaluation_type": "static",
|
||||
"static_bool": False,
|
||||
}
|
||||
)
|
||||
pdf_bytes, _filename = self.receipt_a._render_tax_receipt_pdf()
|
||||
self.assertTrue(pdf_bytes.startswith(b"%PDF"))
|
||||
|
||||
def test_draft_receipt_cannot_be_rendered(self):
|
||||
"""Rendering refuses a receipt that carries no legal number yet."""
|
||||
with self.assertRaisesRegex(UserError, "draft"):
|
||||
self.receipt_draft._render_tax_receipt_pdf()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration safety net
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_report_misconfigured_missing(self):
|
||||
"""No report on the issuing company raises a configuration error."""
|
||||
self.env.company.tax_receipt_report_id = False
|
||||
# Asserted on the company name, not on the translatable wording.
|
||||
with self.assertRaisesRegex(UserError, re.escape(self.env.company.name)):
|
||||
self.receipt_a._render_tax_receipt_pdf()
|
||||
|
||||
def test_report_deleted_unsets_setting(self):
|
||||
"""Deleting the selected report empties the setting, no crash."""
|
||||
report = self._create_pdf_form_report("l10n.fr.tax.receipt", " deleted")
|
||||
self.env.company.tax_receipt_report_id = report
|
||||
report.unlink()
|
||||
self.assertFalse(self.env.company.tax_receipt_report_id)
|
||||
self.assertFalse(self.receipt_a._get_pdf_available_receipts())
|
||||
|
||||
def test_report_misconfigured_wrong_model(self):
|
||||
"""A report retargeted to another model after selection is refused.
|
||||
|
||||
The settings domain prevents selecting it, but the report itself
|
||||
can be edited afterwards; the ORM does not re-check the domain.
|
||||
"""
|
||||
other_report = self._create_pdf_form_report("res.partner")
|
||||
self.env.company.tax_receipt_report_id = other_report
|
||||
with self.assertRaisesRegex(UserError, "res.partner"):
|
||||
self.receipt_a._render_tax_receipt_pdf()
|
||||
|
||||
def test_report_misconfigured_wrong_type(self):
|
||||
"""A report that is not a PDF Form report is refused."""
|
||||
qweb_report = self.env["ir.actions.report"].create(
|
||||
{
|
||||
"name": "Not a PDF Form report",
|
||||
"model": "l10n.fr.tax.receipt",
|
||||
"report_name": "tax_receipt_in_portal.test_qweb",
|
||||
"report_type": "qweb-pdf",
|
||||
}
|
||||
)
|
||||
self.env.company.tax_receipt_report_id = qweb_report
|
||||
with self.assertRaisesRegex(UserError, "qweb-pdf"):
|
||||
self.receipt_a._render_tax_receipt_pdf()
|
||||
|
||||
def test_report_of_issuing_company(self):
|
||||
"""A receipt uses its issuing company's report, not the current one.
|
||||
|
||||
In the portal, the current company is the website's; a receipt
|
||||
issued by another company must still use that company's report.
|
||||
"""
|
||||
other_company = self.env["res.company"].create({"name": "Other issuer"})
|
||||
other_report = self._create_pdf_form_report(
|
||||
"l10n.fr.tax.receipt", " other company"
|
||||
)
|
||||
other_company.tax_receipt_report_id = other_report
|
||||
other_receipt = self.env["l10n.fr.tax.receipt"].create(
|
||||
{
|
||||
"partner_id": self.donor_a.id,
|
||||
"company_id": other_company.id,
|
||||
"year": 2025,
|
||||
}
|
||||
)
|
||||
self.assertEqual(self.env.company.tax_receipt_report_id, self.report)
|
||||
self.assertEqual(other_receipt._get_tax_receipt_report(), other_report)
|
||||
self.assertEqual(self.receipt_a._get_tax_receipt_report(), self.report)
|
||||
|
||||
def test_settings_domain(self):
|
||||
"""The settings selector only offers PDF Form tax receipt reports."""
|
||||
wrong_model = self._create_pdf_form_report("res.partner")
|
||||
qweb_report = self.env["ir.actions.report"].create(
|
||||
{
|
||||
"name": "Not a PDF Form report",
|
||||
"model": "l10n.fr.tax.receipt",
|
||||
"report_name": "tax_receipt_in_portal.test_qweb_domain",
|
||||
"report_type": "qweb-pdf",
|
||||
}
|
||||
)
|
||||
field = self.env["res.company"]._fields["tax_receipt_report_id"]
|
||||
domain = safe_eval(field.domain, {"id": self.env.company.id})
|
||||
selectable = self.env["ir.actions.report"].search(domain)
|
||||
self.assertIn(self.report, selectable)
|
||||
self.assertNotIn(wrong_model, selectable)
|
||||
self.assertNotIn(qweb_report, selectable)
|
||||
|
||||
def test_settings_write_company(self):
|
||||
"""Saving the settings stores the report on the current company."""
|
||||
self.env.company.tax_receipt_report_id = False
|
||||
settings = self.env["res.config.settings"].create(
|
||||
{"tax_receipt_report_id": self.report.id}
|
||||
)
|
||||
settings.execute()
|
||||
self.assertEqual(self.env.company.tax_receipt_report_id, self.report)
|
||||
|
||||
def test_settings_view(self):
|
||||
"""The Tax Receipts block is part of the Invoicing settings."""
|
||||
arch = self.env["res.config.settings"].get_views([(False, "form")])["views"][
|
||||
"form"
|
||||
]["arch"]
|
||||
self.assertIn('id="tax_receipt_settings"', arch)
|
||||
self.assertIn('name="tax_receipt_report_id"', arch)
|
||||
|
||||
def test_compute_api_contract(self):
|
||||
"""Guard the private renderer API this module depends on.
|
||||
|
||||
``_check_report_renderable`` calls ``_compute_pdf_field_value`` from
|
||||
``pdf_form_report``. That method is private, so this test fails
|
||||
loudly if the third-party signature changes, rather than letting a
|
||||
blank tax receipt reach a donor.
|
||||
"""
|
||||
report_model = self.env["ir.actions.report"]
|
||||
self.assertTrue(hasattr(report_model, "_compute_pdf_field_value"))
|
||||
signature = inspect.signature(report_model._compute_pdf_field_value)
|
||||
self.assertEqual(list(signature.parameters), ["field_line", "record"])
|
||||
264
tax_receipt_in_portal/views/portal_templates.xml
Normal file
264
tax_receipt_in_portal/views/portal_templates.xml
Normal file
@@ -0,0 +1,264 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Card on /my/home, using the native portal entry template. -->
|
||||
<template
|
||||
id="portal_my_home_tax_receipt"
|
||||
name="Portal My Home: Tax Receipts"
|
||||
inherit_id="portal.portal_my_home"
|
||||
customize_show="True"
|
||||
priority="40"
|
||||
>
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
|
||||
<t t-set="portal_client_category_enable" t-value="True" />
|
||||
</xpath>
|
||||
<div id="portal_client_category" position="inside">
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">Tax Receipts</t>
|
||||
<t t-set="url" t-value="'/my/tax-receipts'" />
|
||||
<t t-set="text">View and download your yearly donation receipts</t>
|
||||
<t t-set="placeholder_count" t-value="'tax_receipt_count'" />
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Breadcrumb: list page on top, receipt number on the detail page. -->
|
||||
<template
|
||||
id="portal_breadcrumbs_tax_receipt"
|
||||
name="Portal Breadcrumbs: Tax Receipts"
|
||||
inherit_id="portal.portal_breadcrumbs"
|
||||
priority="40"
|
||||
>
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<li
|
||||
t-if="page_name == 'tax_receipt'"
|
||||
t-attf-class="breadcrumb-item #{'active ' if not tax_receipt else ''}"
|
||||
>
|
||||
<a
|
||||
t-if="tax_receipt"
|
||||
t-attf-href="/my/tax-receipts?{{ keep_query() }}"
|
||||
>Tax Receipts</a>
|
||||
<t t-else="">Tax Receipts</t>
|
||||
</li>
|
||||
<!-- page_name is part of the condition for the same reason as
|
||||
account's `portal_invoice` breadcrumb: a leaked
|
||||
`tax_receipt` variable from another portal page body must
|
||||
not activate this item. -->
|
||||
<li
|
||||
t-if="page_name == 'tax_receipt' and tax_receipt"
|
||||
class="breadcrumb-item active"
|
||||
>
|
||||
<t t-out="tax_receipt.name" />
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="portal_my_tax_receipts" name="My Tax Receipts">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True" />
|
||||
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Tax Receipts</t>
|
||||
</t>
|
||||
|
||||
<t t-if="not tax_receipts">
|
||||
<p class="alert alert-warning">
|
||||
There is currently no tax receipt for your account.
|
||||
</p>
|
||||
</t>
|
||||
|
||||
<t t-if="tax_receipts" t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th name="receipt_number">Receipt #</th>
|
||||
<th name="receipt_year">Year</th>
|
||||
<th name="receipt_amount" class="text-end pe-3">
|
||||
Amount
|
||||
</th>
|
||||
<th name="receipt_download" class="text-end" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="tax_receipts" t-as="receipt">
|
||||
<td>
|
||||
<a
|
||||
t-att-href="receipt.get_portal_url()"
|
||||
t-att-title="receipt.name"
|
||||
>
|
||||
<span t-out="receipt.name" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span t-out="receipt.year" />
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<span
|
||||
t-out="receipt.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": receipt.currency_id}'
|
||||
/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a
|
||||
t-if="receipt.id in pdf_available_ids"
|
||||
class="btn btn-sm btn-secondary"
|
||||
t-attf-href="/my/tax-receipts/#{receipt.id}/download"
|
||||
t-att-title="receipt.name"
|
||||
>
|
||||
<i class="fa fa-download me-1" role="img" />
|
||||
<span>PDF</span>
|
||||
</a>
|
||||
<span
|
||||
t-else=""
|
||||
class="text-muted small o_tax_receipt_pdf_pending"
|
||||
>PDF available soon</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_tax_receipt_page" name="My Tax Receipt">
|
||||
<t t-call="portal.portal_layout">
|
||||
<h5 class="mb-0">
|
||||
<span>Tax Receipt - <span t-field="tax_receipt.name" /></span>
|
||||
</h5>
|
||||
<div id="general_information">
|
||||
<div class="row mt-4">
|
||||
<div
|
||||
t-if="tax_receipt.partner_id"
|
||||
class="col-12 col-md-6 mb-4 mb-md-0"
|
||||
>
|
||||
<h6><strong>Donor:</strong></h6>
|
||||
<address
|
||||
t-field="tax_receipt.partner_id"
|
||||
t-options='{"widget": "contact", "fields": ["name", "email", "phone"]}'
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 text-md-end">
|
||||
<a
|
||||
t-if="pdf_available"
|
||||
class="btn btn-primary"
|
||||
t-attf-href="/my/tax-receipts/#{tax_receipt.id}/download#{'?access_token=%s' % access_token if access_token else ''}"
|
||||
>
|
||||
<i class="fa fa-download me-1" role="img" />
|
||||
<span>Download PDF</span>
|
||||
</a>
|
||||
<span
|
||||
t-else=""
|
||||
class="text-muted o_tax_receipt_pdf_pending"
|
||||
>PDF available soon</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4" id="tax_receipt_information">
|
||||
<div class="col-12 col-md-6 mb-4 mb-md-0">
|
||||
<div t-if="tax_receipt.name" class="row mb-2 mb-sm-1">
|
||||
<div class="col-12 col-sm-4"><strong>Receipt #</strong></div>
|
||||
<div class="col-12 col-sm-8">
|
||||
<span t-field="tax_receipt.name" />
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="tax_receipt.year" class="row mb-2 mb-sm-1">
|
||||
<div class="col-12 col-sm-4"><strong>Year</strong></div>
|
||||
<div class="col-12 col-sm-8">
|
||||
<span t-field="tax_receipt.year" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 mb-sm-1">
|
||||
<div class="col-12 col-sm-4"><strong>Status</strong></div>
|
||||
<div class="col-12 col-sm-8">
|
||||
<span t-field="tax_receipt.state" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="row mb-2 mb-sm-1">
|
||||
<div class="col-12 col-sm-4"><strong>Amount</strong></div>
|
||||
<div class="col-12 col-sm-8">
|
||||
<span
|
||||
t-field="tax_receipt.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": tax_receipt.currency_id}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
t-if="tax_receipt.amount_total_words"
|
||||
class="row mb-2 mb-sm-1"
|
||||
>
|
||||
<div class="col-12 col-sm-4"><strong>In words</strong></div>
|
||||
<div class="col-12 col-sm-8">
|
||||
<span t-field="tax_receipt.amount_total_words" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
t-if="tax_receipt.last_payment_date"
|
||||
class="row mb-2 mb-sm-1"
|
||||
>
|
||||
<div
|
||||
class="col-12 col-sm-4"
|
||||
><strong>Last Payment Date</strong></div>
|
||||
<div class="col-12 col-sm-8">
|
||||
<span
|
||||
t-field="tax_receipt.last_payment_date"
|
||||
t-options='{"widget": "date"}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="invoices" id="tax_receipt_invoices" class="mt-4">
|
||||
<h6><strong>Invoices</strong></h6>
|
||||
<table class="table table-sm" id="tax_receipt_invoice_table">
|
||||
<thead class="bg-100">
|
||||
<tr>
|
||||
<th class="text-start">Invoice</th>
|
||||
<th class="text-end">Invoice Date</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- The loop variable must not be named `invoice`:
|
||||
the body of a t-call is rendered before the
|
||||
layout it calls (ir_qweb _compile_directive_call),
|
||||
so a top-level `invoice` variable would leak
|
||||
into the breadcrumb context, where account's
|
||||
portal_breadcrumbs template tests
|
||||
t-if="invoice". -->
|
||||
<tr t-foreach="invoices" t-as="portal_invoice">
|
||||
<td>
|
||||
<a
|
||||
t-attf-href="/my/invoices/#{portal_invoice.id}"
|
||||
t-att-title="portal_invoice.name"
|
||||
>
|
||||
<t t-out="portal_invoice.name" />
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span
|
||||
t-field="portal_invoice.invoice_date"
|
||||
t-options='{"widget": "date"}'
|
||||
/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span
|
||||
t-field="portal_invoice.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": portal_invoice.currency_id}'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tax_receipt_communication" class="mt-4">
|
||||
<!-- mb-4 is required: the frontend portal chatter paints an
|
||||
opaque sticky bar 15px above its own container
|
||||
(portal/static/src/chatter/core/portal_chatter.xml), which
|
||||
would otherwise cover the bottom of this heading. -->
|
||||
<h6 class="mb-4"><strong>Communication</strong></h6>
|
||||
<t t-call="portal.message_thread" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
25
tax_receipt_in_portal/views/res_config_settings_views.xml
Normal file
25
tax_receipt_in_portal/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.tax.receipt.portal</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[@id='invoicing_settings']" position="after">
|
||||
<block title="Tax Receipts" id="tax_receipt_settings">
|
||||
<setting
|
||||
id="tax_receipt_report"
|
||||
string="Tax Receipt Report"
|
||||
company_dependent="1"
|
||||
help="PDF Form report used to render tax receipts downloaded from the portal"
|
||||
>
|
||||
<field
|
||||
name="tax_receipt_report_id"
|
||||
options="{'no_create': True}"
|
||||
/>
|
||||
</setting>
|
||||
</block>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user