[ADD] tax_receipt_in_portal: publish tax receipts on the portal #34
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
from . import l10n_fr_tax_receipt
|
from . import l10n_fr_tax_receipt
|
||||||
|
from . import res_company
|
||||||
|
from . import res_config_settings
|
||||||
|
|||||||
@@ -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``.
|
||||||
|
|
||||||
|
|||||||
29
tax_receipt_in_portal/models/res_company.py
Normal file
29
tax_receipt_in_portal/models/res_company.py
Normal 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.",
|
||||||
|
)
|
||||||
19
tax_receipt_in_portal/models/res_config_settings.py
Normal file
19
tax_receipt_in_portal/models/res_config_settings.py
Normal 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"),
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
25
tax_receipt_in_portal/views/res_config_settings_views.xml
Normal file
25
tax_receipt_in_portal/views/res_config_settings_views.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user