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