[ADD] tax_receipt_in_portal: publish tax receipts on the portal
Let donors view and download their own tax receipts from ``/my/tax-receipts``: a paginated list, a detail page with the receipt information, its invoices and the native portal chatter, and a PDF download rendered on demand through the configured ``pdf_form`` report. The PDF rendering is delegated to ``pdf_form_report`` and nothing is archived: a confirmed receipt is frozen by ``l10n_fr_tax_receipt``, so rendering it again yields the same document. Two safety nets compensate third-party behaviors: - report resolution by ``report_uuid`` (system parameter ``tax_receipt_in_portal.report_uuid``) with strict validation of report type and target model; - a blank-field guard which resolves every mapped PDF field value before rendering, because ``pdf_form_report`` swallows resolution errors and would otherwise silently serve a receipt with a blank amount. Access is enforced twice: a portal record rule (own commercial partner, confirmed/sent state only) and an explicit controller domain that also covers internal users browsing ``/my``. Tested with 17 tests (portal isolation, draft exposure, chatter data access, blank-field guard, report misconfiguration, private API contract of ``pdf_form_report``), including a mutation check proving the security tests fail when the record rule is opened.
This commit is contained in:
1
tax_receipt_in_portal/tests/__init__.py
Normal file
1
tax_receipt_in_portal/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_portal_tax_receipt
|
||||
454
tax_receipt_in_portal/tests/test_portal_tax_receipt.py
Normal file
454
tax_receipt_in_portal/tests/test_portal_tax_receipt.py
Normal file
@@ -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"])
|
||||
Reference in New Issue
Block a user