4 Commits

Author SHA1 Message Date
Stéphan Sainléger
5eb3fe766b [IMP] tax_receipt_in_portal: pick report per company, hide missing PDFs
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m30s
tests / test (pull_request) Failing after 1m41s
Replace the ``tax_receipt_in_portal.report_uuid`` system parameter by a
per-company setting, in Settings > Invoicing > Tax Receipts
(``res.company.tax_receipt_report_id``). Each receipt is rendered with
the report of its issuing company, not the current one: in the portal,
the current company is the website's. The selector only offers PDF Form
reports on ``l10n.fr.tax.receipt``; type and model are still checked at
rendering time since the report can be edited after selection.

The download buttons of the list and detail pages are only shown when
the PDF can actually be rendered: a valid report is configured for the
issuing company and no mapped field resolves to an empty value. The
checks are those of the rendering itself, exposed as a filter
(``_get_pdf_available_receipts``) and run in sudo to avoid false
negatives on field paths the donor cannot read. Otherwise a discreet
"PDF available soon" mention replaces the button, and the download URL
redirects to the receipt page.

The detail page download link now also carries the share token, so a
visitor reaching the page by token can download the PDF.
2026-10-23 13:32:06 +02:00
Stéphan Sainléger
73c0684623 [ADD] tax_receipt_in_portal: publish tax receipts on the portal
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m32s
tests / test (pull_request) Failing after 38s
Let donors view and download their own tax receipts from
``/my/tax-receipts``: a paginated list, a detail page with the receipt
information, its invoices and the native portal chatter, and a PDF
download rendered on demand through the configured ``pdf_form`` report.

The PDF rendering is delegated to ``pdf_form_report`` and nothing is
archived: a confirmed receipt is frozen by ``l10n_fr_tax_receipt``, so
rendering it again yields the same document.

Two safety nets compensate third-party behaviors:

- report resolution by ``report_uuid`` (system parameter
  ``tax_receipt_in_portal.report_uuid``) with strict validation of
  report type and target model;
- a blank-field guard which resolves every mapped PDF field value
  before rendering, because ``pdf_form_report`` swallows resolution
  errors and would otherwise silently serve a receipt with a blank
  amount.

Access is enforced twice: a portal record rule (own commercial partner,
confirmed/sent state only) and an explicit controller domain that also
covers internal users browsing ``/my``.

Tested with 17 tests (portal isolation, draft exposure, chatter data
access, blank-field guard, report misconfiguration, private API
contract of ``pdf_form_report``), including a mutation check proving
the security tests fail when the record rule is opened.
2026-10-21 00:15:21 +02:00
Stéphan Sainléger
e14eafed39 [IMP] global: set correct webiste in manifests 2026-10-20 21:02:05 +02:00
Stéphan Sainléger
78c6de7ecb [MIG] account_invoice_default_journal: migrate to 18.0 2026-10-20 21:02:05 +02:00
37 changed files with 2412 additions and 436 deletions

View File

@@ -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",

View 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.

View File

@@ -1,19 +1,20 @@
# Copyright 2022 Nicolas JEUDY
# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Account Move Last Payment",
"name": "Account Invoice Default Journal",
"version": "18.0.1.0.0",
"summary": "Last real payment date and method on invoices",
"author": "Elabore",
"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",
"category": "Accounting",
"depends": [
"account",
],
"data": [
"views/account_move_views.xml",
"views/product_template_view.xml",
],
"installable": True,
"auto_install": False,

View 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 !"

View File

@@ -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,
)

View File

@@ -0,0 +1,2 @@
from . import product_template
from . import account_move

View 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

View 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.",
)

View File

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

View File

@@ -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,

View File

@@ -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",

View File

@@ -1,33 +0,0 @@
# Account Move Last Payment
Expose on `account.move` the date and method of the most recent real payment reconciled
with an invoice, as two stored, exportable fields shown in the invoice list view.
## Features
- `last_payment_date` (Date): date of the most recent payment reconciled with the
invoice — partial payments included.
- `last_payment_method` (Char): method of that most recent payment: the payment method
line name for explicit payments, "Bank Transfer" when the invoice was settled through
a direct bank statement line reconciliation.
- Covers both Community reconciliation paths: explicit payment ("Register Payment"
wizard) and direct bank statement reconciliation. Manual reconciliations without
payment or statement line are ignored, as are exchange difference moves.
- Recomputes automatically on reconcile / unreconcile and when a payment or statement
line date changes.
- Both fields are stored and displayed as optional columns of the invoice list view, so
they are exportable.
## Installation
Depends on the `account` module only.
## Credits
### Contributors
- Elabore - Assisted by AI
### Maintainer
- Elabore

View File

@@ -1,54 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * account_move_last_payment
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-20 15:23+0000\n"
"PO-Revision-Date: 2026-10-20 15:23+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: account_move_last_payment
#: code:addons/account_move_last_payment/models/account_move.py:0
msgid "Bank Transfer"
msgstr "Virement"
#. module: account_move_last_payment
#: model:ir.model.fields,field_description:account_move_last_payment.field_account_move__last_payment_date
msgid "Last Payment Date"
msgstr "Date du dernier paiement"
#. module: account_move_last_payment
#: model:ir.model.fields,field_description:account_move_last_payment.field_account_move__last_payment_method
msgid "Last Payment Method"
msgstr "Mode de paiement"
#. module: account_move_last_payment
#: model:ir.model.fields,help:account_move_last_payment.field_account_move__last_payment_date
msgid ""
"Date of the most recent payment reconciled with this invoice, whatever "
"the reconciliation path (explicit payment or direct bank statement line "
"reconciliation)."
msgstr ""
"Date du paiement le plus récent lettré avec cette facture, quel que soit "
"le chemin de lettrage (paiement explicite ou rapprochement direct d'une "
"ligne de relevé bancaire)."
#. module: account_move_last_payment
#: model:ir.model.fields,help:account_move_last_payment.field_account_move__last_payment_method
msgid ""
"Payment method of the most recent payment reconciled with this invoice."
msgstr ""
"Mode de paiement du paiement le plus récent lettré avec cette facture."
#. module: account_move_last_payment
#: model:ir.model,name:account_move_last_payment.model_account_move
msgid "Journal Entry"
msgstr "Écriture comptable"

View File

@@ -1 +0,0 @@
from . import account_move

View File

@@ -1,84 +0,0 @@
from odoo import api, fields, models
class AccountMove(models.Model):
_inherit = "account.move"
last_payment_date = fields.Date(
compute="_compute_last_payment_info",
store=True,
copy=False,
help="Date of the most recent payment reconciled with this "
"invoice, whatever the reconciliation path (explicit payment "
"or direct bank statement line reconciliation).",
)
last_payment_method = fields.Char(
compute="_compute_last_payment_info",
store=True,
copy=False,
help="Payment method of the most recent payment reconciled with this invoice.",
)
@api.depends(
"line_ids.matched_debit_ids.debit_move_id.date",
"line_ids.matched_debit_ids.debit_move_id.move_id.origin_payment_id.payment_method_line_id.name",
"line_ids.matched_debit_ids.debit_move_id.move_id.statement_line_id",
"line_ids.matched_credit_ids.credit_move_id.date",
"line_ids.matched_credit_ids.credit_move_id.move_id.origin_payment_id.payment_method_line_id.name",
"line_ids.matched_credit_ids.credit_move_id.move_id.statement_line_id",
)
def _compute_last_payment_info(self):
"""Compute the last real payment date and method.
Only customer and vendor invoices carry the information; every
other move type gets both fields cleared. Candidates are the
reconciliation counterpart lines of the receivable/payable
lines (exchange difference moves excluded). The most recent
candidate wins; on equal dates the first candidate found wins.
"""
for move in self:
if move.move_type not in ("in_invoice", "out_invoice"):
move.last_payment_date = False
move.last_payment_method = False
continue
candidates = move._get_last_payment_candidates()
if not candidates:
move.last_payment_date = False
move.last_payment_method = False
else:
latest_date, latest_method = max(
candidates, key=lambda candidate: candidate[0]
)
move.last_payment_date = latest_date
move.last_payment_method = latest_method
def _get_last_payment_candidates(self):
"""Collect the candidate real payments for this invoice.
A candidate is a ``(date, method)`` tuple built from each
reconciliation counterpart line:
- counterpart move with ``origin_payment_id``: explicit
payment, dated with the payment date, method from the
payment method line;
- counterpart move with ``statement_line_id``: direct bank
statement reconciliation, dated with the counterpart move
date, method fixed to the bank transfer label;
- any other manual reconciliation is ignored.
:return: list of ``(date, method)`` tuples, possibly empty.
"""
self.ensure_one()
invoice_partials, exchange_diff_moves = self._get_reconciled_invoices_partials()
candidates = []
for _partial, _amount, counterpart_line in invoice_partials:
if counterpart_line.move_id.id in exchange_diff_moves:
continue
payment = counterpart_line.move_id.origin_payment_id
if payment:
candidates.append((payment.date, payment.payment_method_line_id.name))
elif counterpart_line.move_id.statement_line_id:
candidates.append(
(counterpart_line.move_id.date, self.env._("Bank Transfer"))
)
return candidates

View File

@@ -1 +0,0 @@
from . import test_last_payment

View File

@@ -1,239 +0,0 @@
from odoo import fields
from odoo.tests import tagged
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
@tagged("post_install", "-at_install")
class TestAccountMoveLastPayment(AccountTestInvoicingCommon):
"""Test the last payment date and method stored on invoices."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.inbound_method_line = cls.company_data[
"default_journal_bank"
].inbound_payment_method_line_ids[0]
@classmethod
def _create_posted_invoice(cls, move_type="out_invoice"):
"""Create and post a simple invoice without taxes.
:param str move_type: the invoice type to create.
:return: the posted account.move record.
"""
return cls.init_invoice(move_type, post=True, amounts=[1000.0], taxes=[])
@classmethod
def _register_payment(cls, invoice, date=None, method_line=None, amount=None):
"""Register a payment on an invoice through the payment wizard.
:param invoice: the account.move to pay.
:param date: the payment date (defaults to today).
:param method_line: the account.payment.method.line to use
(defaults to the bank journal inbound method line).
:param amount: the payment amount (defaults to the invoice
residual).
:return: the created account.payment record.
"""
return (
cls.env["account.payment.register"]
.with_context(
active_model="account.move",
active_ids=invoice.ids,
)
.create(
{
"payment_date": date or fields.Date.today(),
"payment_method_line_id": (
method_line or cls.inbound_method_line
).id,
"amount": amount if amount is not None else invoice.amount_residual,
}
)
._create_payments()
)
def _reconcile_statement_line(self, invoice, amount, date=None):
"""Create a bank statement line and reconcile it with the invoice.
Mirrors the core reconciliation path: the statement line suspense
counterpart is re-accounted to the invoice receivable account,
then both lines are reconciled together.
:param invoice: the account.move to settle.
:param float amount: the statement line amount.
:param date: the statement line date (defaults to today).
:return: the created account.bank.statement.line record.
"""
statement_line = self.env["account.bank.statement.line"].create(
{
"payment_ref": invoice.name,
"journal_id": self.company_data["default_journal_bank"].id,
"partner_id": invoice.partner_id.id,
"amount": amount,
"date": date or fields.Date.today(),
}
)
st_liquidity_lines, st_suspense_lines, _ = statement_line._seek_for_lines()
receivable_lines = invoice.line_ids.filtered(
lambda line: line.account_type == "asset_receivable"
)
st_suspense_lines.account_id = receivable_lines.account_id
(st_suspense_lines + receivable_lines).reconcile()
return statement_line
def test_register_payment(self):
"""Explicit payment: fields match the payment date and method."""
invoice = self._create_posted_invoice()
payment = self._register_payment(invoice)
self.assertEqual(invoice.last_payment_date, payment.date)
self.assertEqual(
invoice.last_payment_method, payment.payment_method_line_id.name
)
def test_bank_statement_reconciliation(self):
"""Direct statement reconciliation: statement date + Bank Transfer."""
invoice = self._create_posted_invoice()
yesterday = fields.Date.subtract(fields.Date.today(), days=1)
statement_line = self._reconcile_statement_line(
invoice, invoice.amount_total, date=yesterday
)
self.assertEqual(invoice.last_payment_date, statement_line.date)
self.assertEqual(invoice.last_payment_method, "Bank Transfer")
def test_list_view_and_export(self):
"""Both fields are stored and shown in the invoice list view."""
self.assertTrue(self.env["account.move"]._fields["last_payment_date"].store)
self.assertTrue(self.env["account.move"]._fields["last_payment_method"].store)
view = self.env["ir.ui.view"]._get_view_id("account.view_invoice_tree")
arch = self.env["account.move"].get_view(view_id=view, view_type="list")["arch"]
self.assertIn('name="last_payment_date"', arch)
self.assertIn('name="last_payment_method"', arch)
def test_multiple_payments_keeps_latest(self):
"""With several payments, the most recent candidate wins."""
invoice = self._create_posted_invoice()
older_date = fields.Date.subtract(fields.Date.today(), days=10)
newer_date = fields.Date.subtract(fields.Date.today(), days=2)
self._register_payment(invoice, date=older_date, amount=400.0)
self._register_payment(invoice, date=newer_date, amount=600.0)
self.assertEqual(invoice.last_payment_date, newer_date)
self.assertEqual(invoice.last_payment_method, self.inbound_method_line.name)
def test_statement_line_date_change_recompute(self):
"""Changing the statement line date updates the stored fields."""
invoice = self._create_posted_invoice()
yesterday = fields.Date.subtract(fields.Date.today(), days=1)
statement_line = self._reconcile_statement_line(
invoice, invoice.amount_total, date=yesterday
)
self.assertEqual(invoice.last_payment_date, yesterday)
older_date = fields.Date.subtract(fields.Date.today(), days=30)
statement_line.date = older_date
self.assertEqual(invoice.last_payment_date, older_date)
def test_unreconcile_clears_fields(self):
"""Unreconciling the invoice clears both fields."""
invoice = self._create_posted_invoice()
self._register_payment(invoice)
invoice.line_ids.remove_move_reconcile()
self.assertFalse(invoice.last_payment_date)
self.assertFalse(invoice.last_payment_method)
def _setup_eur_rates(self, invoice_date, payment_date):
"""Set deterministic EUR rates so the payment creates an
exchange difference move.
:param invoice_date: the invoice posting date.
:param payment_date: the payment date.
:return: the EUR res.currency record.
"""
eur = self.env.ref("base.EUR")
if not eur.active:
eur.active = True
self.env["res.currency.rate"].search(
[
("currency_id", "=", eur.id),
("name", "in", [invoice_date, payment_date]),
]
).unlink()
self.env["res.currency.rate"].create(
[
{"currency_id": eur.id, "name": invoice_date, "rate": 1.0},
{"currency_id": eur.id, "name": payment_date, "rate": 1.1},
]
)
return eur
def test_multicurrency_invoice(self):
"""A foreign currency payment keeps its real date and method,
and the generated exchange difference move is not taken as a
payment candidate."""
payment_date = fields.Date.subtract(fields.Date.today(), days=5)
invoice_date = fields.Date.from_string("2019-01-01")
eur = self._setup_eur_rates(invoice_date, payment_date)
invoice = self.init_invoice("out_invoice", post=False, amounts=[1000.0])
invoice.currency_id = eur
invoice.action_post()
payment = self._register_payment(
invoice,
date=payment_date,
amount=invoice.amount_residual,
)
self.assertTrue(invoice.line_ids.mapped("matched_credit_ids.exchange_move_id"))
candidates = invoice._get_last_payment_candidates()
self.assertEqual(len(candidates), 1)
self.assertEqual(invoice.last_payment_date, payment.date)
self.assertEqual(
invoice.last_payment_method, payment.payment_method_line_id.name
)
def test_draft_invoice_is_empty(self):
"""A draft invoice has never been reconciled, so both fields
stay empty."""
invoice = self.init_invoice("out_invoice", post=False, amounts=[1000.0])
self.assertFalse(invoice.last_payment_date)
self.assertFalse(invoice.last_payment_method)
def test_non_invoice_moves_are_empty(self):
"""Journal entries and credit notes never carry the fields."""
invoice = self._create_posted_invoice()
self._register_payment(invoice)
refund = self._create_posted_invoice(move_type="out_refund")
entry = self.env["account.move"].create(
{
"move_type": "entry",
"journal_id": self.company_data["default_journal_misc"].id,
"date": fields.Date.today(),
"line_ids": [
(
0,
0,
{
"account_id": self.company_data[
"default_account_revenue"
].id,
"debit": 100.0,
},
),
(
0,
0,
{
"account_id": self.company_data[
"default_account_receivable"
].id,
"credit": 100.0,
},
),
],
}
)
entry.action_post()
self.assertFalse(refund.last_payment_date)
self.assertFalse(refund.last_payment_method)
self.assertFalse(entry.last_payment_date)
self.assertFalse(entry.last_payment_method)
self.assertTrue(invoice.last_payment_date)

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_invoice_tree_last_payment" model="ir.ui.view">
<field name="name">account.invoice.list.last.payment</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_invoice_tree"/>
<field name="arch" type="xml">
<field name="amount_residual_signed" position="after">
<field name="last_payment_date" optional="show"/>
<field name="last_payment_method" optional="show"/>
</field>
</field>
</record>
</odoo>

View File

@@ -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,

View File

@@ -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",

View 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.

View File

@@ -0,0 +1,2 @@
from . import controllers
from . import models

View 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,
}

View File

@@ -0,0 +1 @@
from . import portal

View 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)

View 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"

View File

@@ -0,0 +1,3 @@
from . import l10n_fr_tax_receipt
from . import res_company
from . import res_config_settings

View 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

View 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.",
)

View 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"),
)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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

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

View File

@@ -0,0 +1 @@
from . import test_portal_tax_receipt

View 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"])

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

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