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 1/2] [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 @@ + + + + + + + + + + + + + + -- 2.49.1 From 5eb3fe766b4dedf9c7fab39b0c12183cbd410623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Fri, 23 Oct 2026 13:00:01 +0200 Subject: [PATCH 2/2] [IMP] tax_receipt_in_portal: pick report per company, hide missing PDFs Replace the ``tax_receipt_in_portal.report_uuid`` system parameter by a per-company setting, in Settings > Invoicing > Tax Receipts (``res.company.tax_receipt_report_id``). Each receipt is rendered with the report of its issuing company, not the current one: in the portal, the current company is the website's. The selector only offers PDF Form reports on ``l10n.fr.tax.receipt``; type and model are still checked at rendering time since the report can be edited after selection. The download buttons of the list and detail pages are only shown when the PDF can actually be rendered: a valid report is configured for the issuing company and no mapped field resolves to an empty value. The checks are those of the rendering itself, exposed as a filter (``_get_pdf_available_receipts``) and run in sudo to avoid false negatives on field paths the donor cannot read. Otherwise a discreet "PDF available soon" mention replaces the button, and the download URL redirects to the receipt page. The detail page download link now also carries the share token, so a visitor reaching the page by token can download the PDF. --- tax_receipt_in_portal/README.rst | 40 ++-- tax_receipt_in_portal/__manifest__.py | 1 + tax_receipt_in_portal/controllers/portal.py | 23 ++- tax_receipt_in_portal/i18n/fr.po | 56 +++-- tax_receipt_in_portal/models/__init__.py | 2 + .../models/l10n_fr_tax_receipt.py | 88 +++++--- tax_receipt_in_portal/models/res_company.py | 29 +++ .../models/res_config_settings.py | 19 ++ .../tests/test_portal_tax_receipt.py | 195 +++++++++++++++--- .../views/portal_templates.xml | 12 +- .../views/res_config_settings_views.xml | 25 +++ 11 files changed, 397 insertions(+), 93 deletions(-) create mode 100644 tax_receipt_in_portal/models/res_company.py create mode 100644 tax_receipt_in_portal/models/res_config_settings.py create mode 100644 tax_receipt_in_portal/views/res_config_settings_views.xml diff --git a/tax_receipt_in_portal/README.rst b/tax_receipt_in_portal/README.rst index a725c28..35c0200 100644 --- a/tax_receipt_in_portal/README.rst +++ b/tax_receipt_in_portal/README.rst @@ -57,24 +57,26 @@ installed automatically. Configuration ============= -One system parameter is required -(:menuselection:`Settings --> Technical --> System Parameters`): +Select the report rendering tax receipts in +:menuselection:`Settings --> Invoicing --> Tax Receipts --> Tax Receipt +Report`. -==================================== ========================================= -Key Value -==================================== ========================================= -``tax_receipt_in_portal.report_uuid`` The ``report_uuid`` of the PDF Form - report that renders tax receipts. -==================================== ========================================= +The setting is **per company**: each receipt is rendered with the report of +its *issuing* company (``company_id`` of the receipt), not the current one — +in the portal, the current company is the website's. -A UUID is used rather than a database id so the configuration survives a -restore on another database. The report must: +The selector only offers reports that: -* be of type ``PDF Form`` (``report_type = pdf_form``); -* target the model ``l10n.fr.tax.receipt``. +* are of type ``PDF Form`` (``report_type = pdf_form``); +* target the model ``l10n.fr.tax.receipt``; +* belong to the company, or are shared (no company). -Anything else raises an explicit error at download time instead of serving -a wrong document. +These conditions are checked again at rendering time, since a report can be +edited after being selected. Deleting the selected report empties the +setting. + +Without a valid report, donors still see their receipts, but no download +button: a discreet "PDF available soon" mention replaces it. Bind the PDF fields to receipt field paths, for instance: @@ -94,6 +96,12 @@ Usage Donors see only their own receipts, and only once **confirmed**. Confirming a receipt in the back office is therefore what publishes it. +A download button is shown only when the PDF can actually be rendered: a +report is configured for the issuing company and no mapped field resolves +to an empty value. Otherwise the button is replaced by a "PDF available +soon" mention, and a direct call to the download URL redirects to the +receipt page. + Security ======== @@ -142,6 +150,10 @@ 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. +* **Availability is evaluated at each display.** Deciding whether to show + the download button resolves every mapped PDF field of each displayed + receipt — at most one page of the list. A report with costly Python + expressions slows the list down accordingly. * **No email notification** and **no portal account invitation**: a donor without a portal account cannot reach the page. Both are deliberately out of scope. diff --git a/tax_receipt_in_portal/__manifest__.py b/tax_receipt_in_portal/__manifest__.py index e145459..c593219 100644 --- a/tax_receipt_in_portal/__manifest__.py +++ b/tax_receipt_in_portal/__manifest__.py @@ -18,6 +18,7 @@ "security/ir.model.access.csv", "security/ir_rule.xml", "views/portal_templates.xml", + "views/res_config_settings_views.xml", ], "installable": True, "application": False, diff --git a/tax_receipt_in_portal/controllers/portal.py b/tax_receipt_in_portal/controllers/portal.py index 9590466..c434b4b 100644 --- a/tax_receipt_in_portal/controllers/portal.py +++ b/tax_receipt_in_portal/controllers/portal.py @@ -2,6 +2,8 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). """Portal routes exposing a donor's own tax receipts.""" +from werkzeug.urls import url_encode + from odoo import http from odoo.exceptions import AccessError, MissingError from odoo.http import content_disposition, request @@ -97,6 +99,7 @@ class TaxReceiptCustomerPortal(CustomerPortal): "page_name": "tax_receipt", "tax_receipt": receipt_sudo, "invoices": self._tax_receipt_portal_invoices(receipt_sudo), + "pdf_available": bool(receipt_sudo._get_pdf_available_receipts()), } return self._get_page_view_values( receipt_sudo, @@ -139,6 +142,11 @@ class TaxReceiptCustomerPortal(CustomerPortal): values.update( { "tax_receipts": receipts_page, + # Computed on the current page only: at most one page of + # receipts is checked per request. + "pdf_available_ids": set( + receipts_page._get_pdf_available_receipts().ids + ), "page_name": "tax_receipt", "pager": pager, "default_url": "/my/tax-receipts", @@ -191,11 +199,17 @@ class TaxReceiptCustomerPortal(CustomerPortal): rule or on the share token — so a draft receipt or another donor's receipt is refused. + When the PDF cannot be rendered (no report configured for the + issuing company, or a blank mapped field), the visitor is sent back + to the receipt page, which then shows no download button. This + covers a configuration change between page display and click. + :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. + :return: an ``application/pdf`` response, a redirect to the + receipt page when no PDF is available, or a redirect to ``/my`` + when the receipt is not accessible. """ try: receipt_sudo = self._document_check_access( @@ -203,6 +217,11 @@ class TaxReceiptCustomerPortal(CustomerPortal): ) except (AccessError, MissingError): return request.redirect("/my") + if not receipt_sudo._get_pdf_available_receipts(): + detail_url = f"/my/tax-receipts/{receipt_sudo.id}" + if access_token: + detail_url += "?" + url_encode({"access_token": access_token}) + return request.redirect(detail_url) pdf_bytes, filename = receipt_sudo._render_tax_receipt_pdf() headers = [ ("Content-Type", "application/pdf"), diff --git a/tax_receipt_in_portal/i18n/fr.po b/tax_receipt_in_portal/i18n/fr.po index 4a4ef33..d11caaf 100644 --- a/tax_receipt_in_portal/i18n/fr.po +++ b/tax_receipt_in_portal/i18n/fr.po @@ -6,8 +6,8 @@ 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" +"POT-Creation-Date: 2026-10-23 10:55+0000\n" +"PO-Revision-Date: 2026-10-23 10:55+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -27,6 +27,8 @@ msgid "" "\n" " PDF" msgstr "" +"\n" +" PDF" #. module: tax_receipt_in_portal #: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page @@ -92,6 +94,16 @@ msgstr "Avertissement d'accès" msgid "Amount" msgstr "Montant" +#. module: tax_receipt_in_portal +#: model:ir.model,name:tax_receipt_in_portal.model_res_company +msgid "Companies" +msgstr "Sociétés" + +#. module: tax_receipt_in_portal +#: model:ir.model,name:tax_receipt_in_portal.model_res_config_settings +msgid "Config Settings" +msgstr "Paramètres de configuration" + #. 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" @@ -110,20 +122,32 @@ 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." +msgid "" +"No tax receipt report configured for company %(company)s. Select one in " +"Settings > Invoicing > Tax Receipts." msgstr "" -"Aucun rapport trouvé avec le report_uuid %(uuid)s, configuré dans %(param)s." +"Aucun rapport de reçu fiscal configuré pour la société %(company)s. Sélectionnez-en un dans Paramètres > Facturation > Reçus fiscaux." #. 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." +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form +msgid "PDF Form report used to render tax receipts downloaded from the portal" 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." +"Rapport PDF Form utilisé pour générer les reçus fiscaux téléchargés depuis le portail" + +#. module: tax_receipt_in_portal +#: model:ir.model.fields,help:tax_receipt_in_portal.field_res_company__tax_receipt_report_id +#: model:ir.model.fields,help:tax_receipt_in_portal.field_res_config_settings__tax_receipt_report_id +msgid "" +"PDF Form report used to render the tax receipts issued by this company in " +"the portal. Without it, donors can see their receipts but not download them." +msgstr "" +"Rapport PDF Form utilisé pour générer dans le portail les reçus fiscaux émis par cette société. Sans lui, les donateurs voient leurs reçus mais ne peuvent pas les télécharger." + +#. module: tax_receipt_in_portal +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page +msgid "PDF available soon" +msgstr "PDF bientôt disponible" #. module: tax_receipt_in_portal #: model:ir.model.fields,field_description:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url @@ -165,10 +189,18 @@ msgstr "Jeton de sécurité" msgid "Tax Receipt -" msgstr "Reçu fiscal -" +#. module: tax_receipt_in_portal +#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_res_company__tax_receipt_report_id +#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_res_config_settings__tax_receipt_report_id +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form +msgid "Tax Receipt Report" +msgstr "Rapport de 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 +#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form msgid "Tax Receipts" msgstr "Reçus fiscaux" diff --git a/tax_receipt_in_portal/models/__init__.py b/tax_receipt_in_portal/models/__init__.py index c75c93b..a2c5cf8 100644 --- a/tax_receipt_in_portal/models/__init__.py +++ b/tax_receipt_in_portal/models/__init__.py @@ -1 +1,3 @@ from . import l10n_fr_tax_receipt +from . import res_company +from . import res_config_settings diff --git a/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py b/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py index c2bbcb3..ad94606 100644 --- a/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py +++ b/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py @@ -10,12 +10,6 @@ amounts, numbering and the invoice selection belong to 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") @@ -53,39 +47,32 @@ class L10nFrTaxReceipt(models.Model): return result def _get_tax_receipt_report(self): - """Return the ``pdf_form`` report configured to render receipts. + """Return the ``pdf_form`` report rendering this receipt. - The report is looked up by its ``report_uuid`` stored in the - ``tax_receipt_in_portal.report_uuid`` system parameter. + The report is the one selected on the receipt's **issuing** + company (Settings > Invoicing > Tax Receipts), not on the current + company: in the portal, the current company is the website's, + which may differ. + + The type and target model are checked again here although the + settings field restricts the choice: the report can be edited + after being selected. :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``. + :raises UserError: if the company has no report configured, 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) - ) + self.ensure_one() + company = self.sudo().company_id + report = company.tax_receipt_report_id if not report: raise UserError( _( - "No report found with report_uuid %(uuid)s, configured " - "in %(param)s.", - uuid=report_uuid, - param=REPORT_UUID_PARAM, + "No tax receipt report configured for company " + "%(company)s. Select one in Settings > Invoicing > Tax " + "Receipts.", + company=company.name, ) ) if report.report_type != PDF_FORM_REPORT_TYPE: @@ -153,6 +140,43 @@ class L10nFrTaxReceipt(models.Model): ) ) + def _get_pdf_available_receipts(self): + """Return the receipts of ``self`` whose PDF can be rendered. + + Applies exactly the checks of ``_render_tax_receipt_pdf`` — visible + state, configured and valid report, no blank field — but as a + filter instead of raising, so the portal only offers a download + button that works. + + The checks run in sudo, like the rendering itself: evaluated with + the donor's rights, a field path reaching data they cannot read + would resolve to nothing and hide a downloadable receipt. + + The report is resolved once per issuing company. + + :return: a subset of ``self``, in the caller's environment. + """ + reports_by_company = {} + available_ids = [] + for receipt in self.sudo(): + if receipt.state not in PORTAL_VISIBLE_STATES: + continue + company = receipt.company_id + if company not in reports_by_company: + try: + reports_by_company[company] = receipt._get_tax_receipt_report() + except UserError: + reports_by_company[company] = None + report = reports_by_company[company] + if not report: + continue + try: + receipt._check_report_renderable(report) + except UserError: + continue + available_ids.append(receipt.id) + return self.browse(available_ids) + def _render_tax_receipt_pdf(self): """Render this tax receipt as a PDF through ``pdf_form_report``. diff --git a/tax_receipt_in_portal/models/res_company.py b/tax_receipt_in_portal/models/res_company.py new file mode 100644 index 0000000..b18955f --- /dev/null +++ b/tax_receipt_in_portal/models/res_company.py @@ -0,0 +1,29 @@ +# Copyright 2026 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +"""Per-company choice of the report rendering tax receipts.""" + +from odoo import fields, models + +#: Domain restricting the selectable reports to PDF Form reports on tax +#: receipts. Shared by the company field and its settings counterpart. +TAX_RECEIPT_REPORT_DOMAIN = ( + "[('report_type', '=', 'pdf_form')," + " ('model', '=', 'l10n.fr.tax.receipt')," + " ('company_id', 'in', [False, {company}])]" +) + + +class ResCompany(models.Model): + """Store the tax receipt report of each issuing company.""" + + _inherit = "res.company" + + tax_receipt_report_id = fields.Many2one( + "ir.actions.report", + string="Tax Receipt Report", + ondelete="set null", + domain=TAX_RECEIPT_REPORT_DOMAIN.format(company="id"), + help="PDF Form report used to render the tax receipts issued by " + "this company in the portal. Without it, donors can see their " + "receipts but not download them.", + ) diff --git a/tax_receipt_in_portal/models/res_config_settings.py b/tax_receipt_in_portal/models/res_config_settings.py new file mode 100644 index 0000000..2f63bb5 --- /dev/null +++ b/tax_receipt_in_portal/models/res_config_settings.py @@ -0,0 +1,19 @@ +# Copyright 2026 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +"""Expose the tax receipt report in the Invoicing settings.""" + +from odoo import fields, models + +from .res_company import TAX_RECEIPT_REPORT_DOMAIN + + +class ResConfigSettings(models.TransientModel): + """Add the tax receipt report selector to the settings.""" + + _inherit = "res.config.settings" + + tax_receipt_report_id = fields.Many2one( + related="company_id.tax_receipt_report_id", + readonly=False, + domain=TAX_RECEIPT_REPORT_DOMAIN.format(company="company_id"), + ) diff --git a/tax_receipt_in_portal/tests/test_portal_tax_receipt.py b/tax_receipt_in_portal/tests/test_portal_tax_receipt.py index aaab84f..654df15 100644 --- a/tax_receipt_in_portal/tests/test_portal_tax_receipt.py +++ b/tax_receipt_in_portal/tests/test_portal_tax_receipt.py @@ -5,16 +5,15 @@ import base64 import inspect import io +import re from odoo import Command from odoo.exceptions import UserError from odoo.tests import tagged from odoo.tools import mute_logger +from odoo.tools.safe_eval import safe_eval 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. @@ -62,9 +61,7 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon): 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.env.company.tax_receipt_report_id = cls.report cls.donor_a, cls.user_a = cls._create_donor("donor_a") cls.donor_b, cls.user_b = cls._create_donor("donor_b") @@ -80,17 +77,19 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon): # ------------------------------------------------------------------ @classmethod - def _create_pdf_form_report(cls, model_name): + def _create_pdf_form_report(cls, model_name, suffix=""): """Create a ``pdf_form`` report on ``model_name``. :param model_name: technical name of the report target model. + :param suffix: distinguishes several reports on the same model, + whose name and ``report_name`` must stay unique. :return: the created ``ir.actions.report`` record. """ report = cls.env["ir.actions.report"].create( { - "name": f"Tax receipt test ({model_name})", + "name": f"Tax receipt test ({model_name}){suffix}", "model": model_name, - "report_name": f"tax_receipt_in_portal.test_{model_name}", + "report_name": f"tax_receipt_in_portal.test_{model_name}{suffix}", "report_type": "pdf_form", "filename_pattern": "tax_receipt_{object.id}.pdf", } @@ -348,6 +347,84 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon): ) self.assertEqual(result["tax_receipt_count"], 0) + # ------------------------------------------------------------------ + # PDF availability + # ------------------------------------------------------------------ + + #: CSS class of the "PDF available soon" mention, used instead of the + #: translatable wording. + PENDING_CLASS = "o_tax_receipt_pdf_pending" + + def test_no_report_hides_download_buttons(self): + """Without a configured report, no download button is shown. + + The receipt itself stays visible: only its PDF is unavailable. + """ + self.env.company.tax_receipt_report_id = False + 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("/download", listing.text) + self.assertIn(self.PENDING_CLASS, listing.text) + + page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}") + self.assertEqual(page.status_code, 200) + self.assertNotIn("/download", page.text) + self.assertIn(self.PENDING_CLASS, page.text) + + def test_available_pdf_shows_buttons(self): + """With a valid report, both buttons are shown, no pending mention.""" + self._login(self.user_a) + + listing = self.url_open("/my/tax-receipts") + self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", listing.text) + self.assertNotIn(self.PENDING_CLASS, listing.text) + + page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}") + self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", page.text) + self.assertNotIn(self.PENDING_CLASS, page.text) + + @mute_logger(PDF_FORM_LOGGER) + def test_blank_field_hides_button_for_that_receipt_only(self): + """A receipt with a blank mapped field loses its button, not others.""" + older_receipt = self._create_receipt(self.donor_a, 2023, 50.0) + older_receipt.action_confirm() + self.env["pdf.form.field.line"].create( + { + "report_id": self.report.id, + "pdf_field_name": "ONLY_2025", + "pdf_field_type": "text", + "is_acroform": False, + "page": 1, + "evaluation_type": "equation", + "python_expr": "'ok' if object.year == 2025 else ''", + } + ) + self._login(self.user_a) + + listing = self.url_open("/my/tax-receipts") + self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", listing.text) + self.assertNotIn(f"/my/tax-receipts/{older_receipt.id}/download", listing.text) + self.assertEqual(listing.text.count(self.PENDING_CLASS), 1) + + def test_download_unavailable_redirects_to_receipt(self): + """Downloading an unavailable PDF goes back to the receipt page.""" + self.env.company.tax_receipt_report_id = False + self._login(self.user_a) + + response = self.url_open( + f"/my/tax-receipts/{self.receipt_a.id}/download", + allow_redirects=False, + ) + self.assertIn(response.status_code, (302, 303)) + self.assertTrue( + response.headers["Location"].endswith( + f"/my/tax-receipts/{self.receipt_a.id}" + ) + ) + # ------------------------------------------------------------------ # Rendering safety net # ------------------------------------------------------------------ @@ -397,35 +474,34 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon): # 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): + def test_report_misconfigured_missing(self): + """No report on the issuing company raises a configuration error.""" + self.env.company.tax_receipt_report_id = False + # Asserted on the company name, not on the translatable wording. + with self.assertRaisesRegex(UserError, re.escape(self.env.company.name)): 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_deleted_unsets_setting(self): + """Deleting the selected report empties the setting, no crash.""" + report = self._create_pdf_form_report("l10n.fr.tax.receipt", " deleted") + self.env.company.tax_receipt_report_id = report + report.unlink() + self.assertFalse(self.env.company.tax_receipt_report_id) + self.assertFalse(self.receipt_a._get_pdf_available_receipts()) def test_report_misconfigured_wrong_model(self): - """A report targeting another model is refused.""" + """A report retargeted to another model after selection is refused. + + The settings domain prevents selecting it, but the report itself + can be edited afterwards; the ORM does not re-check the domain. + """ other_report = self._create_pdf_form_report("res.partner") - self.env["ir.config_parameter"].sudo().set_param( - REPORT_UUID_PARAM, other_report.report_uuid - ) + self.env.company.tax_receipt_report_id = other_report 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. - """ + """A report that is not a PDF Form report is refused.""" qweb_report = self.env["ir.actions.report"].create( { "name": "Not a PDF Form report", @@ -434,12 +510,67 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon): "report_type": "qweb-pdf", } ) - self.env["ir.config_parameter"].sudo().set_param( - REPORT_UUID_PARAM, qweb_report.report_uuid - ) + self.env.company.tax_receipt_report_id = qweb_report with self.assertRaisesRegex(UserError, "qweb-pdf"): self.receipt_a._render_tax_receipt_pdf() + def test_report_of_issuing_company(self): + """A receipt uses its issuing company's report, not the current one. + + In the portal, the current company is the website's; a receipt + issued by another company must still use that company's report. + """ + other_company = self.env["res.company"].create({"name": "Other issuer"}) + other_report = self._create_pdf_form_report( + "l10n.fr.tax.receipt", " other company" + ) + other_company.tax_receipt_report_id = other_report + other_receipt = self.env["l10n.fr.tax.receipt"].create( + { + "partner_id": self.donor_a.id, + "company_id": other_company.id, + "year": 2025, + } + ) + self.assertEqual(self.env.company.tax_receipt_report_id, self.report) + self.assertEqual(other_receipt._get_tax_receipt_report(), other_report) + self.assertEqual(self.receipt_a._get_tax_receipt_report(), self.report) + + def test_settings_domain(self): + """The settings selector only offers PDF Form tax receipt reports.""" + wrong_model = self._create_pdf_form_report("res.partner") + 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_domain", + "report_type": "qweb-pdf", + } + ) + field = self.env["res.company"]._fields["tax_receipt_report_id"] + domain = safe_eval(field.domain, {"id": self.env.company.id}) + selectable = self.env["ir.actions.report"].search(domain) + self.assertIn(self.report, selectable) + self.assertNotIn(wrong_model, selectable) + self.assertNotIn(qweb_report, selectable) + + def test_settings_write_company(self): + """Saving the settings stores the report on the current company.""" + self.env.company.tax_receipt_report_id = False + settings = self.env["res.config.settings"].create( + {"tax_receipt_report_id": self.report.id} + ) + settings.execute() + self.assertEqual(self.env.company.tax_receipt_report_id, self.report) + + def test_settings_view(self): + """The Tax Receipts block is part of the Invoicing settings.""" + arch = self.env["res.config.settings"].get_views([(False, "form")])["views"][ + "form" + ]["arch"] + self.assertIn('id="tax_receipt_settings"', arch) + self.assertIn('name="tax_receipt_report_id"', arch) + def test_compute_api_contract(self): """Guard the private renderer API this module depends on. diff --git a/tax_receipt_in_portal/views/portal_templates.xml b/tax_receipt_in_portal/views/portal_templates.xml index d7a0ff3..34c61c4 100644 --- a/tax_receipt_in_portal/views/portal_templates.xml +++ b/tax_receipt_in_portal/views/portal_templates.xml @@ -99,6 +99,7 @@ PDF + PDF available soon @@ -132,12 +137,17 @@
Download PDF + PDF available soon
diff --git a/tax_receipt_in_portal/views/res_config_settings_views.xml b/tax_receipt_in_portal/views/res_config_settings_views.xml new file mode 100644 index 0000000..841ffaa --- /dev/null +++ b/tax_receipt_in_portal/views/res_config_settings_views.xml @@ -0,0 +1,25 @@ + + + + res.config.settings.view.form.tax.receipt.portal + res.config.settings + + + + + + + + + + + + -- 2.49.1