[ADD] tax_receipt_in_portal: publish tax receipts on the portal #34

Open
stephansainleger wants to merge 2 commits from 18.0-tax_receipt_in_portal into 18.0
11 changed files with 397 additions and 93 deletions
Showing only changes of commit 5eb3fe766b - Show all commits

View File

@@ -57,24 +57,26 @@ installed automatically.
Configuration Configuration
============= =============
One system parameter is required Select the report rendering tax receipts in
(:menuselection:`Settings --> Technical --> System Parameters`): :menuselection:`Settings --> Invoicing --> Tax Receipts --> Tax Receipt
Report`.
==================================== ========================================= The setting is **per company**: each receipt is rendered with the report of
Key Value its *issuing* company (``company_id`` of the receipt), not the current one —
==================================== ========================================= in the portal, the current company is the website's.
``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 The selector only offers reports that:
restore on another database. The report must:
* be of type ``PDF Form`` (``report_type = pdf_form``); * are of type ``PDF Form`` (``report_type = pdf_form``);
* target the model ``l10n.fr.tax.receipt``. * 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 These conditions are checked again at rendering time, since a report can be
a wrong document. 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: 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 Donors see only their own receipts, and only once **confirmed**. Confirming
a receipt in the back office is therefore what publishes it. 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 Security
======== ========
@@ -142,6 +150,10 @@ Known issues / Roadmap
* **No archiving.** The PDF is rendered at each download and never stored. * **No archiving.** The PDF is rendered at each download and never stored.
A receipt whose rendering becomes impossible (report deleted or A receipt whose rendering becomes impossible (report deleted or
reconfigured) can no longer be served, even though it was already sent. 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 * **No email notification** and **no portal account invitation**: a donor
without a portal account cannot reach the page. Both are deliberately without a portal account cannot reach the page. Both are deliberately
out of scope. out of scope.

View File

@@ -18,6 +18,7 @@
"security/ir.model.access.csv", "security/ir.model.access.csv",
"security/ir_rule.xml", "security/ir_rule.xml",
"views/portal_templates.xml", "views/portal_templates.xml",
"views/res_config_settings_views.xml",
], ],
"installable": True, "installable": True,
"application": False, "application": False,

View File

@@ -2,6 +2,8 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""Portal routes exposing a donor's own tax receipts.""" """Portal routes exposing a donor's own tax receipts."""
from werkzeug.urls import url_encode
from odoo import http from odoo import http
from odoo.exceptions import AccessError, MissingError from odoo.exceptions import AccessError, MissingError
from odoo.http import content_disposition, request from odoo.http import content_disposition, request
@@ -97,6 +99,7 @@ class TaxReceiptCustomerPortal(CustomerPortal):
"page_name": "tax_receipt", "page_name": "tax_receipt",
"tax_receipt": receipt_sudo, "tax_receipt": receipt_sudo,
"invoices": self._tax_receipt_portal_invoices(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( return self._get_page_view_values(
receipt_sudo, receipt_sudo,
@@ -139,6 +142,11 @@ class TaxReceiptCustomerPortal(CustomerPortal):
values.update( values.update(
{ {
"tax_receipts": receipts_page, "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", "page_name": "tax_receipt",
"pager": pager, "pager": pager,
"default_url": "/my/tax-receipts", "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 rule or on the share token — so a draft receipt or another donor's
receipt is refused. 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 receipt_id: database id of the requested receipt.
:param access_token: share token allowing access without :param access_token: share token allowing access without
ownership. ownership.
:return: an ``application/pdf`` response, or a redirect to :return: an ``application/pdf`` response, a redirect to the
``/my`` when the receipt is not accessible. receipt page when no PDF is available, or a redirect to ``/my``
when the receipt is not accessible.
""" """
try: try:
receipt_sudo = self._document_check_access( receipt_sudo = self._document_check_access(
@@ -203,6 +217,11 @@ class TaxReceiptCustomerPortal(CustomerPortal):
) )
except (AccessError, MissingError): except (AccessError, MissingError):
return request.redirect("/my") 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() pdf_bytes, filename = receipt_sudo._render_tax_receipt_pdf()
headers = [ headers = [
("Content-Type", "application/pdf"), ("Content-Type", "application/pdf"),

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Odoo Server 18.0\n" "Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-20 21:59+0000\n" "POT-Creation-Date: 2026-10-23 10:55+0000\n"
"PO-Revision-Date: 2026-10-20 21:59+0000\n" "PO-Revision-Date: 2026-10-23 10:55+0000\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -27,6 +27,8 @@ msgid ""
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n" "<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
" <span>PDF</span>" " <span>PDF</span>"
msgstr "" msgstr ""
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
" <span>PDF</span>"
#. module: tax_receipt_in_portal #. module: tax_receipt_in_portal
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page #: 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" msgid "Amount"
msgstr "Montant" 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 #. module: tax_receipt_in_portal
#: model:ir.model.fields,help:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url #: model:ir.model.fields,help:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url
msgid "Customer Portal URL" msgid "Customer Portal URL"
@@ -110,20 +122,32 @@ msgstr "Date de facture"
#. module: tax_receipt_in_portal #. module: tax_receipt_in_portal
#. odoo-python #. odoo-python
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0 #: 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 "" 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 #. module: tax_receipt_in_portal
#. odoo-python #: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0 msgid "PDF Form report used to render tax receipts downloaded from the portal"
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 "" msgstr ""
"Aucun rapport de reçu fiscal configuré. Renseignez le paramètre système " "Rapport PDF Form utilisé pour générer les reçus fiscaux téléchargés depuis le portail"
"%(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,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 #. module: tax_receipt_in_portal
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url #: 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 -" msgid "Tax Receipt -"
msgstr "Reçu fiscal -" 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 #. 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_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_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.portal_my_tax_receipts
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
msgid "Tax Receipts" msgid "Tax Receipts"
msgstr "Reçus fiscaux" msgstr "Reçus fiscaux"

View File

@@ -1 +1,3 @@
from . import l10n_fr_tax_receipt from . import l10n_fr_tax_receipt
from . import res_company
from . import res_config_settings

View File

@@ -10,12 +10,6 @@ amounts, numbering and the invoice selection belong to
from odoo import _, models from odoo import _, models
from odoo.exceptions import UserError 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 #: Receipt states a donor is allowed to see and download. A draft receipt
#: carries no legal number yet and must never reach the portal. #: carries no legal number yet and must never reach the portal.
PORTAL_VISIBLE_STATES = ("confirmed", "sent") PORTAL_VISIBLE_STATES = ("confirmed", "sent")
@@ -53,39 +47,32 @@ class L10nFrTaxReceipt(models.Model):
return result return result
def _get_tax_receipt_report(self): 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 The report is the one selected on the receipt's **issuing**
``tax_receipt_in_portal.report_uuid`` system parameter. 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. :return: a singleton ``ir.actions.report`` recordset, sudoed.
:raises UserError: if the parameter is unset, if no report carries :raises UserError: if the company has no report configured, if the
that UUID, if the report is not a ``pdf_form`` report, or if it report is not a ``pdf_form`` report, or if it does not target
does not target ``l10n.fr.tax.receipt``. ``l10n.fr.tax.receipt``.
""" """
params = self.env["ir.config_parameter"].sudo() self.ensure_one()
report_uuid = (params.get_param(REPORT_UUID_PARAM) or "").strip() company = self.sudo().company_id
if not report_uuid: report = company.tax_receipt_report_id
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: if not report:
raise UserError( raise UserError(
_( _(
"No report found with report_uuid %(uuid)s, configured " "No tax receipt report configured for company "
"in %(param)s.", "%(company)s. Select one in Settings > Invoicing > Tax "
uuid=report_uuid, "Receipts.",
param=REPORT_UUID_PARAM, company=company.name,
) )
) )
if report.report_type != PDF_FORM_REPORT_TYPE: 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): def _render_tax_receipt_pdf(self):
"""Render this tax receipt as a PDF through ``pdf_form_report``. """Render this tax receipt as a PDF through ``pdf_form_report``.

View File

@@ -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.",
)

View File

@@ -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"),
)

View File

@@ -5,16 +5,15 @@
import base64 import base64
import inspect import inspect
import io import io
import re
from odoo import Command from odoo import Command
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.tests import tagged from odoo.tests import tagged
from odoo.tools import mute_logger 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.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 #: Logger of the third-party renderer, which downgrades field resolution
#: errors to warnings. Muted where a test triggers one on purpose. #: 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("NUMBER", "name")
cls._map_field("DONOR", "partner_id.name") cls._map_field("DONOR", "partner_id.name")
cls._map_field("AMOUNT", "amount_total_words") cls._map_field("AMOUNT", "amount_total_words")
cls.env["ir.config_parameter"].sudo().set_param( cls.env.company.tax_receipt_report_id = cls.report
REPORT_UUID_PARAM, cls.report.report_uuid
)
cls.donor_a, cls.user_a = cls._create_donor("donor_a") cls.donor_a, cls.user_a = cls._create_donor("donor_a")
cls.donor_b, cls.user_b = cls._create_donor("donor_b") cls.donor_b, cls.user_b = cls._create_donor("donor_b")
@@ -80,17 +77,19 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@classmethod @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``. """Create a ``pdf_form`` report on ``model_name``.
:param model_name: technical name of the report target model. :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. :return: the created ``ir.actions.report`` record.
""" """
report = cls.env["ir.actions.report"].create( 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, "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", "report_type": "pdf_form",
"filename_pattern": "tax_receipt_{object.id}.pdf", "filename_pattern": "tax_receipt_{object.id}.pdf",
} }
@@ -348,6 +347,84 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
) )
self.assertEqual(result["tax_receipt_count"], 0) 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 # Rendering safety net
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -397,35 +474,34 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
# Configuration safety net # Configuration safety net
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_report_misconfigured_missing_param(self): def test_report_misconfigured_missing(self):
"""An unset report parameter raises instead of rendering blindly.""" """No report on the issuing company raises a configuration error."""
self.env["ir.config_parameter"].sudo().set_param(REPORT_UUID_PARAM, "") self.env.company.tax_receipt_report_id = False
with self.assertRaisesRegex(UserError, REPORT_UUID_PARAM): # 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() self.receipt_a._render_tax_receipt_pdf()
def test_report_misconfigured_unknown_uuid(self): def test_report_deleted_unsets_setting(self):
"""An unknown report UUID raises a configuration error.""" """Deleting the selected report empties the setting, no crash."""
self.env["ir.config_parameter"].sudo().set_param( report = self._create_pdf_form_report("l10n.fr.tax.receipt", " deleted")
REPORT_UUID_PARAM, "no-such-uuid" self.env.company.tax_receipt_report_id = report
) report.unlink()
with self.assertRaisesRegex(UserError, "no-such-uuid"): self.assertFalse(self.env.company.tax_receipt_report_id)
self.receipt_a._render_tax_receipt_pdf() self.assertFalse(self.receipt_a._get_pdf_available_receipts())
def test_report_misconfigured_wrong_model(self): 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") other_report = self._create_pdf_form_report("res.partner")
self.env["ir.config_parameter"].sudo().set_param( self.env.company.tax_receipt_report_id = other_report
REPORT_UUID_PARAM, other_report.report_uuid
)
with self.assertRaisesRegex(UserError, "res.partner"): with self.assertRaisesRegex(UserError, "res.partner"):
self.receipt_a._render_tax_receipt_pdf() self.receipt_a._render_tax_receipt_pdf()
def test_report_misconfigured_wrong_type(self): def test_report_misconfigured_wrong_type(self):
"""A report that is not a PDF Form report is refused. """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( qweb_report = self.env["ir.actions.report"].create(
{ {
"name": "Not a PDF Form report", "name": "Not a PDF Form report",
@@ -434,12 +510,67 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
"report_type": "qweb-pdf", "report_type": "qweb-pdf",
} }
) )
self.env["ir.config_parameter"].sudo().set_param( self.env.company.tax_receipt_report_id = qweb_report
REPORT_UUID_PARAM, qweb_report.report_uuid
)
with self.assertRaisesRegex(UserError, "qweb-pdf"): with self.assertRaisesRegex(UserError, "qweb-pdf"):
self.receipt_a._render_tax_receipt_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): def test_compute_api_contract(self):
"""Guard the private renderer API this module depends on. """Guard the private renderer API this module depends on.

View File

@@ -99,6 +99,7 @@
</td> </td>
<td class="text-end"> <td class="text-end">
<a <a
t-if="receipt.id in pdf_available_ids"
class="btn btn-sm btn-secondary" class="btn btn-sm btn-secondary"
t-attf-href="/my/tax-receipts/#{receipt.id}/download" t-attf-href="/my/tax-receipts/#{receipt.id}/download"
t-att-title="receipt.name" t-att-title="receipt.name"
@@ -106,6 +107,10 @@
<i class="fa fa-download me-1" role="img" /> <i class="fa fa-download me-1" role="img" />
<span>PDF</span> <span>PDF</span>
</a> </a>
<span
t-else=""
class="text-muted small o_tax_receipt_pdf_pending"
>PDF available soon</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -132,12 +137,17 @@
</div> </div>
<div class="col-12 col-md-6 text-md-end"> <div class="col-12 col-md-6 text-md-end">
<a <a
t-if="pdf_available"
class="btn btn-primary" class="btn btn-primary"
t-attf-href="/my/tax-receipts/#{tax_receipt.id}/download" t-attf-href="/my/tax-receipts/#{tax_receipt.id}/download#{'?access_token=%s' % access_token if access_token else ''}"
> >
<i class="fa fa-download me-1" role="img" /> <i class="fa fa-download me-1" role="img" />
<span>Download PDF</span> <span>Download PDF</span>
</a> </a>
<span
t-else=""
class="text-muted o_tax_receipt_pdf_pending"
>PDF available soon</span>
</div> </div>
</div> </div>
<div class="row mt-4" id="tax_receipt_information"> <div class="row mt-4" id="tax_receipt_information">

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.tax.receipt.portal</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//block[@id='invoicing_settings']" position="after">
<block title="Tax Receipts" id="tax_receipt_settings">
<setting
id="tax_receipt_report"
string="Tax Receipt Report"
company_dependent="1"
help="PDF Form report used to render tax receipts downloaded from the portal"
>
<field
name="tax_receipt_report_id"
options="{'no_create': True}"
/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>