[ADD] tax_receipt_in_portal: publish tax receipts on the portal
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m32s
tests / test (pull_request) Failing after 38s

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:
Stéphan Sainléger
2026-10-21 00:15:21 +02:00
parent e14eafed39
commit 73c0684623
13 changed files with 1589 additions and 0 deletions

View File

@@ -0,0 +1,254 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Card on /my/home, using the native portal entry template. -->
<template
id="portal_my_home_tax_receipt"
name="Portal My Home: Tax Receipts"
inherit_id="portal.portal_my_home"
customize_show="True"
priority="40"
>
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
<t t-set="portal_client_category_enable" t-value="True" />
</xpath>
<div id="portal_client_category" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">Tax Receipts</t>
<t t-set="url" t-value="'/my/tax-receipts'" />
<t t-set="text">View and download your yearly donation receipts</t>
<t t-set="placeholder_count" t-value="'tax_receipt_count'" />
</t>
</div>
</template>
<!-- Breadcrumb: list page on top, receipt number on the detail page. -->
<template
id="portal_breadcrumbs_tax_receipt"
name="Portal Breadcrumbs: Tax Receipts"
inherit_id="portal.portal_breadcrumbs"
priority="40"
>
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
<li
t-if="page_name == 'tax_receipt'"
t-attf-class="breadcrumb-item #{'active ' if not tax_receipt else ''}"
>
<a
t-if="tax_receipt"
t-attf-href="/my/tax-receipts?{{ keep_query() }}"
>Tax Receipts</a>
<t t-else="">Tax Receipts</t>
</li>
<!-- page_name is part of the condition for the same reason as
account's `portal_invoice` breadcrumb: a leaked
`tax_receipt` variable from another portal page body must
not activate this item. -->
<li
t-if="page_name == 'tax_receipt' and tax_receipt"
class="breadcrumb-item active"
>
<t t-out="tax_receipt.name" />
</li>
</xpath>
</template>
<template id="portal_my_tax_receipts" name="My Tax Receipts">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True" />
<t t-call="portal.portal_searchbar">
<t t-set="title">Tax Receipts</t>
</t>
<t t-if="not tax_receipts">
<p class="alert alert-warning">
There is currently no tax receipt for your account.
</p>
</t>
<t t-if="tax_receipts" t-call="portal.portal_table">
<thead>
<tr class="active">
<th name="receipt_number">Receipt #</th>
<th name="receipt_year">Year</th>
<th name="receipt_amount" class="text-end pe-3">
Amount
</th>
<th name="receipt_download" class="text-end" />
</tr>
</thead>
<tbody>
<tr t-foreach="tax_receipts" t-as="receipt">
<td>
<a
t-att-href="receipt.get_portal_url()"
t-att-title="receipt.name"
>
<span t-out="receipt.name" />
</a>
</td>
<td>
<span t-out="receipt.year" />
</td>
<td class="text-end pe-3">
<span
t-out="receipt.amount_total"
t-options='{"widget": "monetary", "display_currency": receipt.currency_id}'
/>
</td>
<td class="text-end">
<a
class="btn btn-sm btn-secondary"
t-attf-href="/my/tax-receipts/#{receipt.id}/download"
t-att-title="receipt.name"
>
<i class="fa fa-download me-1" role="img" />
<span>PDF</span>
</a>
</td>
</tr>
</tbody>
</t>
</t>
</template>
<template id="portal_tax_receipt_page" name="My Tax Receipt">
<t t-call="portal.portal_layout">
<h5 class="mb-0">
<span>Tax Receipt - <span t-field="tax_receipt.name" /></span>
</h5>
<div id="general_information">
<div class="row mt-4">
<div
t-if="tax_receipt.partner_id"
class="col-12 col-md-6 mb-4 mb-md-0"
>
<h6><strong>Donor:</strong></h6>
<address
t-field="tax_receipt.partner_id"
t-options='{"widget": "contact", "fields": ["name", "email", "phone"]}'
/>
</div>
<div class="col-12 col-md-6 text-md-end">
<a
class="btn btn-primary"
t-attf-href="/my/tax-receipts/#{tax_receipt.id}/download"
>
<i class="fa fa-download me-1" role="img" />
<span>Download PDF</span>
</a>
</div>
</div>
<div class="row mt-4" id="tax_receipt_information">
<div class="col-12 col-md-6 mb-4 mb-md-0">
<div t-if="tax_receipt.name" class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4"><strong>Receipt #</strong></div>
<div class="col-12 col-sm-8">
<span t-field="tax_receipt.name" />
</div>
</div>
<div t-if="tax_receipt.year" class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4"><strong>Year</strong></div>
<div class="col-12 col-sm-8">
<span t-field="tax_receipt.year" />
</div>
</div>
<div class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4"><strong>Status</strong></div>
<div class="col-12 col-sm-8">
<span t-field="tax_receipt.state" />
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4"><strong>Amount</strong></div>
<div class="col-12 col-sm-8">
<span
t-field="tax_receipt.amount_total"
t-options='{"widget": "monetary", "display_currency": tax_receipt.currency_id}'
/>
</div>
</div>
<div
t-if="tax_receipt.amount_total_words"
class="row mb-2 mb-sm-1"
>
<div class="col-12 col-sm-4"><strong>In words</strong></div>
<div class="col-12 col-sm-8">
<span t-field="tax_receipt.amount_total_words" />
</div>
</div>
<div
t-if="tax_receipt.last_payment_date"
class="row mb-2 mb-sm-1"
>
<div
class="col-12 col-sm-4"
><strong>Last Payment Date</strong></div>
<div class="col-12 col-sm-8">
<span
t-field="tax_receipt.last_payment_date"
t-options='{"widget": "date"}'
/>
</div>
</div>
</div>
</div>
<div t-if="invoices" id="tax_receipt_invoices" class="mt-4">
<h6><strong>Invoices</strong></h6>
<table class="table table-sm" id="tax_receipt_invoice_table">
<thead class="bg-100">
<tr>
<th class="text-start">Invoice</th>
<th class="text-end">Invoice Date</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
<!-- The loop variable must not be named `invoice`:
the body of a t-call is rendered before the
layout it calls (ir_qweb _compile_directive_call),
so a top-level `invoice` variable would leak
into the breadcrumb context, where account's
portal_breadcrumbs template tests
t-if="invoice". -->
<tr t-foreach="invoices" t-as="portal_invoice">
<td>
<a
t-attf-href="/my/invoices/#{portal_invoice.id}"
t-att-title="portal_invoice.name"
>
<t t-out="portal_invoice.name" />
</a>
</td>
<td class="text-end">
<span
t-field="portal_invoice.invoice_date"
t-options='{"widget": "date"}'
/>
</td>
<td class="text-end">
<span
t-field="portal_invoice.amount_total"
t-options='{"widget": "monetary", "display_currency": portal_invoice.currency_id}'
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="tax_receipt_communication" class="mt-4">
<!-- mb-4 is required: the frontend portal chatter paints an
opaque sticky bar 15px above its own container
(portal/static/src/chatter/core/portal_chatter.xml), which
would otherwise cover the bottom of this heading. -->
<h6 class="mb-4"><strong>Communication</strong></h6>
<t t-call="portal.message_thread" />
</div>
</t>
</template>
</odoo>