[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.
This commit is contained in:
Stéphan Sainléger
2026-10-21 00:15:21 +02:00
parent e14eafed39
commit 73c0684623
13 changed files with 1589 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
=====================
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
=============
One system parameter is required
(:menuselection:`Settings --> Technical --> System Parameters`):
==================================== =========================================
Key Value
==================================== =========================================
``tax_receipt_in_portal.report_uuid`` The ``report_uuid`` of the PDF Form
report that renders tax receipts.
==================================== =========================================
A UUID is used rather than a database id so the configuration survives a
restore on another database. The report must:
* be of type ``PDF Form`` (``report_type = pdf_form``);
* target the model ``l10n.fr.tax.receipt``.
Anything else raises an explicit error at download time instead of serving
a wrong document.
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.
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.
* **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,25 @@
# 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",
],
"installable": True,
"application": False,
"auto_install": False,
}

View File

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

View File

@@ -0,0 +1,212 @@
# 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 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),
}
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,
"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.
:param receipt_id: database id of the requested receipt.
:param access_token: share token allowing access without
ownership.
:return: an ``application/pdf`` response, 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")
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,219 @@
# 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-20 21:59+0000\n"
"PO-Revision-Date: 2026-10-20 21:59+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 ""
#. 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.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 report found with report_uuid %(uuid)s, configured in %(param)s."
msgstr ""
"Aucun rapport trouvé avec le report_uuid %(uuid)s, configuré dans %(param)s."
#. 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. Set the system parameter %(param)s to the "
"report_uuid of the PDF Form report that renders tax receipts."
msgstr ""
"Aucun rapport de reçu fiscal configuré. Renseignez le paramètre système "
"%(param)s avec le report_uuid du rapport PDF Form qui génère les reçus "
"fiscaux."
#. 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_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
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 @@
from . import l10n_fr_tax_receipt

View File

@@ -0,0 +1,192 @@
# 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
#: ``ir.config_parameter`` key holding the ``report_uuid`` of the
#: ``pdf_form`` report used to render a tax receipt. A UUID is used rather
#: than a database id so the configuration survives a restore on another
#: database.
REPORT_UUID_PARAM = "tax_receipt_in_portal.report_uuid"
#: 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 configured to render receipts.
The report is looked up by its ``report_uuid`` stored in the
``tax_receipt_in_portal.report_uuid`` system parameter.
:return: a singleton ``ir.actions.report`` recordset, sudoed.
:raises UserError: if the parameter is unset, if no report carries
that UUID, if the report is not a ``pdf_form`` report, or if it
does not target ``l10n.fr.tax.receipt``.
"""
params = self.env["ir.config_parameter"].sudo()
report_uuid = (params.get_param(REPORT_UUID_PARAM) or "").strip()
if not report_uuid:
raise UserError(
_(
"No tax receipt report configured. Set the system "
"parameter %(param)s to the report_uuid of the PDF Form "
"report that renders tax receipts.",
param=REPORT_UUID_PARAM,
)
)
report = (
self.env["ir.actions.report"]
.sudo()
.search([("report_uuid", "=", report_uuid)], limit=1)
)
if not report:
raise UserError(
_(
"No report found with report_uuid %(uuid)s, configured "
"in %(param)s.",
uuid=report_uuid,
param=REPORT_UUID_PARAM,
)
)
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 _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,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,454 @@
# 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
from odoo import Command
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
from odoo.addons.tax_receipt_in_portal.models.l10n_fr_tax_receipt import (
REPORT_UUID_PARAM,
)
#: 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["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, cls.report.report_uuid
)
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):
"""Create a ``pdf_form`` report on ``model_name``.
:param model_name: technical name of the report target model.
:return: the created ``ir.actions.report`` record.
"""
report = cls.env["ir.actions.report"].create(
{
"name": f"Tax receipt test ({model_name})",
"model": model_name,
"report_name": f"tax_receipt_in_portal.test_{model_name}",
"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)
# ------------------------------------------------------------------
# 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_param(self):
"""An unset report parameter raises instead of rendering blindly."""
self.env["ir.config_parameter"].sudo().set_param(REPORT_UUID_PARAM, "")
with self.assertRaisesRegex(UserError, REPORT_UUID_PARAM):
self.receipt_a._render_tax_receipt_pdf()
def test_report_misconfigured_unknown_uuid(self):
"""An unknown report UUID raises a configuration error."""
self.env["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, "no-such-uuid"
)
with self.assertRaisesRegex(UserError, "no-such-uuid"):
self.receipt_a._render_tax_receipt_pdf()
def test_report_misconfigured_wrong_model(self):
"""A report targeting another model is refused."""
other_report = self._create_pdf_form_report("res.partner")
self.env["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, other_report.report_uuid
)
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.
``pdf_form_report`` adds ``report_uuid`` to every report, so a
plain QWeb report can be pointed at by mistake.
"""
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["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, qweb_report.report_uuid
)
with self.assertRaisesRegex(UserError, "qweb-pdf"):
self.receipt_a._render_tax_receipt_pdf()
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,254 @@
<?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
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>
</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
class="btn btn-primary"
t-attf-href="/my/tax-receipts/#{tax_receipt.id}/download"
>
<i class="fa fa-download me-1" role="img" />
<span>Download PDF</span>
</a>
</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>