[ADD] tax_receipt_in_portal: publish tax receipts on the portal
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:
1
tax_receipt_in_portal/models/__init__.py
Normal file
1
tax_receipt_in_portal/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import l10n_fr_tax_receipt
|
||||
192
tax_receipt_in_portal/models/l10n_fr_tax_receipt.py
Normal file
192
tax_receipt_in_portal/models/l10n_fr_tax_receipt.py
Normal 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
|
||||
Reference in New Issue
Block a user