# Copyright 2026 Elabore # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). """Portal routes exposing a donor's own tax receipts.""" from odoo import http from odoo.exceptions import AccessError, MissingError from odoo.http import content_disposition, request from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.addons.portal.controllers.portal import pager as portal_pager from ..models.l10n_fr_tax_receipt import PORTAL_VISIBLE_STATES #: Technical name of the published model, kept in one place so the routes #: and the access check cannot drift apart. TAX_RECEIPT_MODEL = "l10n.fr.tax.receipt" #: Session key storing the receipts most recently browsed, used by the #: portal to render prev/next links on the detail page. SESSION_HISTORY_KEY = "my_tax_receipts_history" class TaxReceiptCustomerPortal(CustomerPortal): """Expose confirmed tax receipts to the donor they were issued to.""" def _get_tax_receipts_domain(self): """Return the domain restricting receipts to the connected donor. The record rule shipped with this module already enforces the same restriction for portal users, but an *internal* user browsing ``/my`` is not subject to it. Repeating the filter here keeps the page honest for every kind of user. Donations billed to a child contact are included, mirroring how Odoo scopes portal invoices to the commercial entity. :return: an Odoo domain (list of tuples). """ commercial_partner = request.env.user.partner_id.commercial_partner_id return [ ("partner_id", "child_of", commercial_partner.id), ("state", "in", list(PORTAL_VISIBLE_STATES)), ] def _prepare_home_portal_values(self, counters): """Add the tax receipt counter to the portal home page. :param counters: counter names requested by the portal home. :return: the portal home values, augmented with ``tax_receipt_count`` when requested. """ values = super()._prepare_home_portal_values(counters) if "tax_receipt_count" in counters: receipts = request.env[TAX_RECEIPT_MODEL] values["tax_receipt_count"] = ( receipts.search_count(self._get_tax_receipts_domain()) if receipts.has_access("read") else 0 ) return values def _tax_receipt_portal_invoices(self, receipt_sudo): """Return the receipt invoices the current visitor may read. Receipt invoices are created in the back office by accountants, so nothing guarantees the donor is allowed to read them: the portal rule of ``account.move`` requires a posted move the partner follows. Browsing the moves with the visitor's own environment and filtering on the effective access right keeps the detail page free of dead links. :param receipt_sudo: ``l10n.fr.tax.receipt`` singleton, in sudo. :return: an ``account.move`` recordset, possibly empty, readable by the current user and restricted to posted moves. """ receipt_sudo.ensure_one() posted_invoices = receipt_sudo.invoice_ids.filtered( lambda move: move.state == "posted" ) return ( request.env["account.move"] .browse(posted_invoices.ids) ._filtered_access("read") ) def _tax_receipt_get_page_view_values(self, receipt_sudo, access_token, **kwargs): """Build the rendering values of the tax receipt detail page. :param receipt_sudo: ``l10n.fr.tax.receipt`` singleton, in sudo. :param access_token: share token used to reach the page, or ``None``. :param kwargs: extra values forwarded to ``_get_page_view_values``. :return: dict of QWeb values, including the prev/next record browsing keys computed by the portal from the session history. """ values = { "page_name": "tax_receipt", "tax_receipt": receipt_sudo, "invoices": self._tax_receipt_portal_invoices(receipt_sudo), } return self._get_page_view_values( receipt_sudo, access_token, values, SESSION_HISTORY_KEY, False, **kwargs, ) @http.route( ["/my/tax-receipts", "/my/tax-receipts/page/"], type="http", auth="user", website=True, ) def portal_my_tax_receipts(self, page=1, **kw): """List the connected donor's tax receipts, most recent first. :param page: 1-based page number for the pager. :return: the rendered portal list page. """ values = self._prepare_portal_layout_values() receipts = request.env[TAX_RECEIPT_MODEL] domain = self._get_tax_receipts_domain() total = receipts.search_count(domain) pager = portal_pager( url="/my/tax-receipts", total=total, page=page, step=self._items_per_page, ) receipts_page = receipts.search( domain, limit=self._items_per_page, offset=pager["offset"], order="year desc, name desc", ) request.session[SESSION_HISTORY_KEY] = receipts_page.ids[:100] values.update( { "tax_receipts": receipts_page, "page_name": "tax_receipt", "pager": pager, "default_url": "/my/tax-receipts", } ) return request.render("tax_receipt_in_portal.portal_my_tax_receipts", values) @http.route( ["/my/tax-receipts/"], type="http", auth="public", website=True, ) def portal_my_tax_receipt_detail(self, receipt_id, access_token=None, **kw): """Render one tax receipt, by ownership or by share token. The page shows the receipt details, its invoices and the portal chatter, with a download link for the PDF. :param receipt_id: database id of the receipt. :param access_token: share token allowing access without ownership. :param kw: extra query string arguments forwarded to the view values. :return: the rendered detail page, or a redirect to ``/my`` when the record is missing or not accessible. """ try: receipt_sudo = self._document_check_access( TAX_RECEIPT_MODEL, receipt_id, access_token ) except (AccessError, MissingError): return request.redirect("/my") values = self._tax_receipt_get_page_view_values( receipt_sudo, access_token, **kw ) return request.render("tax_receipt_in_portal.portal_tax_receipt_page", values) @http.route( ["/my/tax-receipts//download"], type="http", auth="public", website=True, ) def portal_tax_receipt_download(self, receipt_id, access_token=None, **kw): """Serve the PDF of one tax receipt belonging to the donor. The PDF is rendered on demand; nothing is archived. Access is delegated to the standard portal check, which relies on the record rule or on the share token — so a draft receipt or another donor's receipt is refused. :param receipt_id: database id of the requested receipt. :param access_token: share token allowing access without ownership. :return: an ``application/pdf`` response, or a redirect to ``/my`` when the receipt is not accessible. """ try: receipt_sudo = self._document_check_access( TAX_RECEIPT_MODEL, receipt_id, access_token ) except (AccessError, MissingError): return request.redirect("/my") pdf_bytes, filename = receipt_sudo._render_tax_receipt_pdf() headers = [ ("Content-Type", "application/pdf"), ("Content-Length", len(pdf_bytes)), ("Content-Disposition", content_disposition(filename)), ] return request.make_response(pdf_bytes, headers=headers)