2 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
16 changed files with 1893 additions and 0 deletions

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>