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