From 73c0684623e2b3c4170441f3cca57195ff5d0583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Wed, 21 Oct 2026 00:15:21 +0200 Subject: [PATCH] [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. --- tax_receipt_in_portal/README.rst | 197 ++++++++ tax_receipt_in_portal/__init__.py | 2 + tax_receipt_in_portal/__manifest__.py | 25 + tax_receipt_in_portal/controllers/__init__.py | 1 + tax_receipt_in_portal/controllers/portal.py | 212 ++++++++ tax_receipt_in_portal/i18n/fr.po | 219 +++++++++ tax_receipt_in_portal/models/__init__.py | 1 + .../models/l10n_fr_tax_receipt.py | 192 ++++++++ .../security/ir.model.access.csv | 2 + tax_receipt_in_portal/security/ir_rule.xml | 29 ++ tax_receipt_in_portal/tests/__init__.py | 1 + .../tests/test_portal_tax_receipt.py | 454 ++++++++++++++++++ .../views/portal_templates.xml | 254 ++++++++++ 13 files changed, 1589 insertions(+) create mode 100644 tax_receipt_in_portal/README.rst create mode 100644 tax_receipt_in_portal/__init__.py create mode 100644 tax_receipt_in_portal/__manifest__.py create mode 100644 tax_receipt_in_portal/controllers/__init__.py create mode 100644 tax_receipt_in_portal/controllers/portal.py create mode 100644 tax_receipt_in_portal/i18n/fr.po create mode 100644 tax_receipt_in_portal/models/__init__.py create mode 100644 tax_receipt_in_portal/models/l10n_fr_tax_receipt.py create mode 100644 tax_receipt_in_portal/security/ir.model.access.csv create mode 100644 tax_receipt_in_portal/security/ir_rule.xml create mode 100644 tax_receipt_in_portal/tests/__init__.py create mode 100644 tax_receipt_in_portal/tests/test_portal_tax_receipt.py create mode 100644 tax_receipt_in_portal/views/portal_templates.xml diff --git a/tax_receipt_in_portal/README.rst b/tax_receipt_in_portal/README.rst new file mode 100644 index 0000000..a725c28 --- /dev/null +++ b/tax_receipt_in_portal/README.rst @@ -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/`` 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//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 +`_. 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. diff --git a/tax_receipt_in_portal/__init__.py b/tax_receipt_in_portal/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/tax_receipt_in_portal/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/tax_receipt_in_portal/__manifest__.py b/tax_receipt_in_portal/__manifest__.py new file mode 100644 index 0000000..e145459 --- /dev/null +++ b/tax_receipt_in_portal/__manifest__.py @@ -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, +} diff --git a/tax_receipt_in_portal/controllers/__init__.py b/tax_receipt_in_portal/controllers/__init__.py new file mode 100644 index 0000000..8c3feb6 --- /dev/null +++ b/tax_receipt_in_portal/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/tax_receipt_in_portal/controllers/portal.py b/tax_receipt_in_portal/controllers/portal.py new file mode 100644 index 0000000..9590466 --- /dev/null +++ b/tax_receipt_in_portal/controllers/portal.py @@ -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/"], + 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/"], + 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//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) diff --git a/tax_receipt_in_portal/i18n/fr.po b/tax_receipt_in_portal/i18n/fr.po new file mode 100644 index 0000000..4a4ef33 --- /dev/null +++ b/tax_receipt_in_portal/i18n/fr.po @@ -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 "" +"\n" +" PDF" +msgstr "" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "" +"\n" +" Download PDF" +msgstr "" +"\n" +" Télécharger le PDF" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Amount" +msgstr "Montant" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Communication" +msgstr "Communication" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Donor:" +msgstr "Donateur :" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "In words" +msgstr "En lettres" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Invoices" +msgstr "Factures" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Last Payment Date" +msgstr "Date du dernier versement" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Receipt #" +msgstr "N° de reçu" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Status" +msgstr "État" + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "Year" +msgstr "Année" + +#. 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" diff --git a/tax_receipt_in_portal/models/__init__.py b/tax_receipt_in_portal/models/__init__.py new file mode 100644 index 0000000..c75c93b --- /dev/null +++ b/tax_receipt_in_portal/models/__init__.py @@ -0,0 +1 @@ +from . import l10n_fr_tax_receipt diff --git a/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py b/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py new file mode 100644 index 0000000..c2bbcb3 --- /dev/null +++ b/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py @@ -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 diff --git a/tax_receipt_in_portal/security/ir.model.access.csv b/tax_receipt_in_portal/security/ir.model.access.csv new file mode 100644 index 0000000..16c7325 --- /dev/null +++ b/tax_receipt_in_portal/security/ir.model.access.csv @@ -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 diff --git a/tax_receipt_in_portal/security/ir_rule.xml b/tax_receipt_in_portal/security/ir_rule.xml new file mode 100644 index 0000000..3aafab7 --- /dev/null +++ b/tax_receipt_in_portal/security/ir_rule.xml @@ -0,0 +1,29 @@ + + + + + Portal: own confirmed tax receipts + + + [('partner_id', 'child_of', [user.commercial_partner_id.id]), + ('state', 'in', ['confirmed', 'sent'])] + + + + + + + + diff --git a/tax_receipt_in_portal/tests/__init__.py b/tax_receipt_in_portal/tests/__init__.py new file mode 100644 index 0000000..ae90bbe --- /dev/null +++ b/tax_receipt_in_portal/tests/__init__.py @@ -0,0 +1 @@ +from . import test_portal_tax_receipt diff --git a/tax_receipt_in_portal/tests/test_portal_tax_receipt.py b/tax_receipt_in_portal/tests/test_portal_tax_receipt.py new file mode 100644 index 0000000..aaab84f --- /dev/null +++ b/tax_receipt_in_portal/tests/test_portal_tax_receipt.py @@ -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"]) diff --git a/tax_receipt_in_portal/views/portal_templates.xml b/tax_receipt_in_portal/views/portal_templates.xml new file mode 100644 index 0000000..d7a0ff3 --- /dev/null +++ b/tax_receipt_in_portal/views/portal_templates.xml @@ -0,0 +1,254 @@ + + + + + + + + + + + + + +