diff --git a/my_compassion/__manifest__.py b/my_compassion/__manifest__.py index 115ad51c1..459206a31 100644 --- a/my_compassion/__manifest__.py +++ b/my_compassion/__manifest__.py @@ -91,6 +91,9 @@ "templates/components/my2_giving_limits_modal.xml", "templates/components/my2_checkout.xml", "templates/components/my2_weather_time_container.xml", + "templates/components/my2_sponsorships_section.xml", + "templates/components/my2_payment_method_modal.xml", + "templates/components/my2_payment_method_card.xml", # Other data the depends on the templates "data/my2_new_sponsorship_wizard_steps.xml", ], diff --git a/my_compassion/controllers/my2_donations.py b/my_compassion/controllers/my2_donations.py index 1eb723638..28413b2bb 100644 --- a/my_compassion/controllers/my2_donations.py +++ b/my_compassion/controllers/my2_donations.py @@ -8,18 +8,26 @@ # ############################################################################## +import json import math from collections import defaultdict from datetime import datetime, timedelta from werkzeug.exceptions import BadRequest, NotFound -from odoo import fields, http +import odoo +from odoo import _, fields, http from odoo.http import request from odoo.addons.portal.controllers.portal import CustomerPortal +# TODO: Refactor payment-related methods into a separate controller +# PLEASE NOTE: +# Before deep diving into this code note that there's a documentation +# page explaining the overall architecture and flow of this code. +# https://compassion.odoo.com/odoo/knowledge/202 and +# https://compassion.odoo.com/odoo/knowledge/205 class MyCompassionDonationsController(CustomerPortal): @http.route( '/my2/gifts/', @@ -387,6 +395,10 @@ def _get_paid_invoices_amount(self, partner): ) return number_of_paid_invoices + # ------------------------------------------------------------------------- + # Methods and routes for MyCompassion2.0 Donations Page + # ------------------------------------------------------------------------- + @http.route( "/my2/donations", type="http", @@ -397,7 +409,14 @@ def my_donations(self, invoice_page=1, invoice_per_page=12, **kw): partner = request.env.user.partner_id # Active sponsorships - active_sponsorships = partner.get_portal_sponsorships("active") + active_sponsorships = partner.get_portal_sponsorships(["active", "mandate"]) + + # Group sponsorships by their backend Contract Group + sponsorship_groups = active_sponsorships.mapped("group_id") + + # Put all payment methods into a dict of group_id -> method info + all_groups = partner.get_payment_modes() + payment_info_map = all_groups.get_payment_method_info() # Due invoices date_filter_up_bound = datetime.today() + timedelta(days=30) @@ -417,16 +436,16 @@ def my_donations(self, invoice_page=1, invoice_per_page=12, **kw): ) ) - # Computing the total price of the active sponsorships grouped - # per sponsorship frequency and payment method. - # group_id groups the invoices that have the same payment method and frequency. + # Total cost calculation tot_cost_per_frequency = defaultdict(lambda: defaultdict(float)) for sponsorship in active_sponsorships: currency = sponsorship.pricelist_id.currency_id.name - tot_cost_per_frequency[sponsorship.group_id.month_interval][ - currency - ] += sponsorship.total_amount + # Ensure group exists + if sponsorship.group_id: + tot_cost_per_frequency[sponsorship.group_id.month_interval][ + currency + ] += sponsorship.total_amount paid_invoices_data = self._get_paginated_paid_invoices( partner, invoice_page, invoice_per_page @@ -436,6 +455,9 @@ def my_donations(self, invoice_page=1, invoice_per_page=12, **kw): values.update( { "active_sponsorships": active_sponsorships, + "sponsorship_groups": sponsorship_groups, + "payment_info_map": payment_info_map, + "payment_methods_json": json.dumps(payment_info_map), "tot_cost_per_frequency": tot_cost_per_frequency, "due_invoices": due_invoices, "paid_invoices_subset": paid_invoices_data["paid_invoices_subset"], @@ -466,6 +488,219 @@ def my_donations_history(self, invoice_page=1, invoice_per_page=12, **kw): return {"html": html} + @http.route( + "/my2/donation/change_method_contract", type="json", auth="user", website=True + ) + def change_payment_method_contract(self, contract_id, group_id, **kwargs): + """ + Changes the payment method for a specific contract. + + :param contract_id: ID of the recurring.contract to update. + :param group_id: ID of an existing group to merge into + """ + partner = request.env.user.partner_id + if not contract_id or not group_id: + raise BadRequest() + # Verify that the contract belongs to the user + contract = ( + request.env["recurring.contract"] + .sudo() + .search([("id", "=", int(contract_id)), ("partner_id", "=", partner.id)]) + ) + if not contract: + raise NotFound() + + success = contract.change_contract_group(int(group_id)) + if success: + # Render the updated list + values = self._prepare_sponsorship_values(partner) + html = request.env["ir.qweb"]._render( + "my_compassion.my2_sponsorships_section", values + ) + return { + "success": True, + "html": html, + "payment_info_map": values["payment_info_map"], + } + + return {"success": False, "error": _("Operation failed")} + + @http.route( + "/my2/donation/change_method_group", type="json", auth="user", website=True + ) + def change_payment_method_group( + self, group_id, new_group_id=None, new_bvr_ref=None, **kwargs + ): + """ + Endpoint to update payment method for a sponsorship group. + Accepts new_group_id (to merge) or new_bvr_ref (to update ref). + """ + partner = request.env.user.partner_id + + if not group_id: + raise BadRequest(_("Group ID is required.")) + + # Security Check: Search ensures the group belongs to the logged-in user + group = ( + request.env["recurring.contract.group"] + .sudo() + .search( + [("id", "=", int(group_id)), ("partner_id", "=", partner.id)], limit=1 + ) + ) + + if not group: + raise NotFound(_("Payment group not found or access denied.")) + + # Call the model method to perform the logic + success = group.change_payment_method( + new_group_id=new_group_id, new_bvr_ref=new_bvr_ref + ) + + if success: + values = self._prepare_sponsorship_values(partner) + html = request.env["ir.qweb"]._render( + "my_compassion.my2_sponsorships_section", values + ) + return { + "success": True, + "html": html, + "payment_info_map": values["payment_info_map"], + } + + return {"success": False, "error": _("Operation failed")} + + @http.route( + "/my2/donation/add_payment_method_group", type="json", auth="user", website=True + ) + def add_payment_method_group( + self, + recurring_unit="month", + method_type="bvr", + advance_billing_months=1, + **kwargs, + ): + """ + Creates a new Contract Group with manual BVR/Permanent Order details. + """ + partner = request.env.user.partner_id + + # 1. Find Payment Mode + payment_mode = self._find_manual_payment_mode(method_type) + if not payment_mode: + return { + "success": False, + "error": _('Configuration Error: Payment mode for "%s" not found.') + % method_type, + } + + # 2. Create the Group + try: + new_group = self._create_contract_group( + partner, payment_mode, recurring_unit, advance_billing_months + ) + + # Specific BVR Logic + new_bvr_ref = new_group.compute_partner_bvr_ref(partner) + if new_bvr_ref: + new_group.bvr_reference = new_bvr_ref + + # 3. Return HTML + values = self._prepare_sponsorship_values(partner) + html = request.env["ir.qweb"]._render( + "my_compassion.my2_sponsorships_section", values + ) + + return { + "success": True, + "html": html, + "group_id": new_group.id, + "payment_info_map": values["payment_info_map"], + } + + except odoo.exceptions.ValidationError as e: + return {"success": False, "error": str(e)} + except Exception: + return {"success": False, "error": _("An unexpected error occurred.")} + + @http.route( + "/my2/donation/fetch_payment_methods_iframe", + type="json", + auth="user", + website=True, + ) + def fetch_payment_methods_iframe( + self, recurring_unit="month", recurring_value=1, **kwargs + ): + """ + Initiates a 'validation' transaction to tokenize a card/method. + Returns data for rendering the PostFinance Iframe with available methods. + """ + acquirer = self._get_payment_acquirer() + + if not acquirer.exists(): + return {"success": False, "error": "No payment provider found"} + + # Prepare Transaction + return_url = "/my2/donations?unit={}&val={}".format( + recurring_unit, recurring_value + ) + + # 3. Get Integration Data (Iframe) + # This calls the method overridden in country specific module + result_data = self._prepare_iframe_redirect(acquirer, return_url) + + if ( + result_data + and isinstance(result_data, dict) + and result_data.get("type") == "iframe" + ): + return { + "success": True, + "iframe_url": result_data["url"], + "pf_methods": result_data.get("pf_methods", []), + } + + # Error if Iframe data is missing + return {"success": False, "error": "Payment interface could not be loaded."} + + # ------------------------------------------------------------------------- + # PRIVATE HELPERS (Rendering Data Preparation) + # ------------------------------------------------------------------------- + + @staticmethod + def _prepare_sponsorship_values(partner): + """ + Helper to fetch all data required for the sponsorship list view. + Returns a dict of values for QWeb rendering. + """ + # 1. Fetch Active Sponsorships + active_sponsorships = partner.get_portal_sponsorships(["active", "mandate"]) + + # 2. Fetch Groups + sponsorship_groups = active_sponsorships.mapped("group_id") + + # 3. Calculate Totals + tot_cost_per_frequency = defaultdict(lambda: defaultdict(float)) + for sponsorship in active_sponsorships: + currency = sponsorship.pricelist_id.currency_id.name + if sponsorship.group_id: + tot_cost_per_frequency[sponsorship.group_id.month_interval][ + currency + ] += sponsorship.total_amount + + # 4. Fetch Available Methods (for modals) + all_groups = partner.get_payment_modes() + payment_info_map = all_groups.get_payment_method_info() + + return { + "active_sponsorships": active_sponsorships, + "sponsorship_groups": sponsorship_groups, + "tot_cost_per_frequency": tot_cost_per_frequency, + "payment_info_map": payment_info_map, + "payment_methods_json": json.dumps(payment_info_map), + } + def _get_paginated_paid_invoices( self, partner, invoice_page=1, invoice_per_page=12 ): @@ -489,6 +724,54 @@ def _get_paginated_paid_invoices( "total_pages": total_pages, } + @staticmethod + def _create_contract_group(partner, payment_mode, unit, value, token=None): + """ + Centralized method to create a recurring contract group. + Used by both Manual (BVR, etc.) and Online (Credit Card, etc.) flows. + """ + vals = { + "partner_id": partner.id, + "payment_mode_id": payment_mode.id, + "recurring_unit": unit, + "recurring_value": int(value), + "active": True, + } + if token: + vals["payment_token_id"] = token.id + + return request.env["recurring.contract.group"].sudo().create(vals) + + @staticmethod + def _find_manual_payment_mode(method_key): + """ + Finds a payment mode based on the frontend key (bvr/permanent). + Handles case-insensitive search and archiving. + """ + # Map frontend keys to DB names + search_map = { + "permanent_order": "Permanent Order", + "bvr": "BVR", + } + term = search_map.get(method_key) + if not term: + return None + + # Search with active_test=False to find modes even if archived/hidden + domain = [("name", "=", term)] + mode = ( + request.env["account.payment.mode"] + .sudo() + .with_context(active_test=False) + .search(domain, limit=1) + ) + + return mode + + def _prepare_iframe_redirect(self, acquirer, return_url): + """Method to be overridden by country/provider specific modules""" + return False + @staticmethod def _get_payment_acquirer(): return ( diff --git a/my_compassion/data/payment_options.xml b/my_compassion/data/payment_options.xml new file mode 100644 index 000000000..267d50b95 --- /dev/null +++ b/my_compassion/data/payment_options.xml @@ -0,0 +1,18 @@ + + + + + eBill + + variable + + + + + + eBill + ebill + inbound + + + diff --git a/my_compassion/models/contract_group.py b/my_compassion/models/contract_group.py index 39ca65c25..853666679 100644 --- a/my_compassion/models/contract_group.py +++ b/my_compassion/models/contract_group.py @@ -6,16 +6,33 @@ # The licence is in the file __manifest__.py # ############################################################################## -from odoo import fields, models +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from odoo import _, api, fields, models class ContractGroup(models.Model): _name = "recurring.contract.group" _inherit = ["recurring.contract.group", "translatable.model"] - gender = fields.Selection(store=False) + active = fields.Boolean(default=True) + payment_token_id = fields.Many2one("payment.token", string="Payment Token") + gender = fields.Selection(related="partner_id.gender", store=True, readonly=True) total_amount = fields.Float(compute="_compute_total_amount") + active_contract_count = fields.Integer( + string="Active Contracts Count", compute="_compute_active_contract_count" + ) + + @api.depends("contract_ids.state") + def _compute_active_contract_count(self): + for group in self: + group.active_contract_count = len( + group.contract_ids.filtered( + lambda s: s.state not in ["terminated", "cancelled"] + ) + ) + def _compute_total_amount(self): for group in self: group.total_amount = sum( @@ -23,3 +40,207 @@ def _compute_total_amount(self): lambda s: s.state not in ["terminated", "cancelled"] ).mapped("total_amount") ) + + def get_payment_method_info(self): + """ + Returns a dictionary mapping group IDs to their payment method info. + The info includes icon ID, reference number, label, expiration date, + whether it's a card, payment mode ID, and group ID. + """ + # 1. Prefetch all icons to avoid N+1 queries + all_icons = self.env["payment.icon"].sudo().search([("image", "!=", False)]) + + result_map = {} + + for group in self: + info = { + "icon": False, + "ref_number": False, + "label": _("Unknown Method"), + "expire_date": False, + "is_card": False, + "mode_id": group.payment_mode_id.id if group.payment_mode_id else False, + "group_id": group.id, + } + + search_term = False + + # Logic: Online Token + if group.payment_token_id: + info["is_card"] = True + token_name = group.payment_token_id.name or "" + brand_name = ( + token_name.split("_")[0] if "_" in token_name else token_name + ) + info["label"] = brand_name + search_term = brand_name + + # Logic: Manual Mode + elif group.payment_mode_id: + info["type"] = "mode" + info["label"] = group.payment_mode_id.name + if group.bvr_reference: + info["ref_number"] = group.bvr_reference + search_term = group.payment_mode_id.name + + # Icon Lookup (In-Memory) + if search_term: + found_icon = all_icons.filtered( + lambda i, term=search_term: i.name.lower() == term.lower() + or term.lower() in i.name.lower() + ) + if found_icon: + info["icon"] = found_icon[0].id + + result_map[group.id] = info + + return result_map + + def change_payment_method(self, new_group_id=None, new_bvr_ref=None): + """ + Update the contract group by merging into an existing group + (if new_group_id provided) or updating the BVR reference + (if new_bvr_ref provided). + + :param new_group_id: ID of the target group to merge into + :param new_bvr_ref: New BVR reference string + :return: True if successful, False otherwise + """ + self.ensure_one() + + # Merge into another Payment Group + if new_group_id: + target_group = self.env["recurring.contract.group"].browse( + int(new_group_id) + ) + + # Validation: Target must exist and belong to the same partner + if not target_group.exists() or target_group.partner_id != self.partner_id: + return False + + # Avoid self-merge + if target_group.id == self.id: + return True + + # Move all contracts to the target group + self.active_contract_ids.write({"group_id": target_group.id}) + return True + + # Update Reference (e.g. manual BVR or LSV reference update) + if new_bvr_ref is not None: + # Updating the reference for the current group + self.write({"bvr_reference": new_bvr_ref}) + return True + + return False + + @api.model + def create_from_transaction(self, tx): + """ + Creates or retrieves a contract group from a validation transaction. + + :param tx: payment.transaction record + :return: (group_record, message_string) + """ + if not tx or not tx.payment_token_id: + return { + "group": self.browse(), + "status": "error", + "message": _("No valid payment method found."), + } + token = tx.payment_token_id + + # Reuse existing group if available + existing_group = self.with_context(active_test=False).search( + [ + ("partner_id", "=", tx.partner_id.id), + ("payment_token_id", "=", token.id), + ], + limit=1, + ) + + if existing_group: + # Reactivate if it was archived + if not existing_group.active: + existing_group.active = True + return { + "group": existing_group, + "status": "existing", + "message": _("This payment method was already saved."), + } + # Retrieve Recurring Frequency (Unit/Value) + # Default to monthly if not specified + recurring_unit = "month" + recurring_value = 1 + + if tx.return_url: + try: + parsed = urlparse(tx.return_url) + params = parse_qs(parsed.query) + if "unit" in params: + recurring_unit = params["unit"][0] + if "val" in params: + recurring_value = int(params["val"][0]) + # Clean Arguments(Remove used keys) + # We use pop(key, None) to avoid errors if the key is missing + params.pop("unit", None) + params.pop("val", None) + + # Rebuild the return_url without the used parameters + new_query = urlencode(params, doseq=True) + url_parts = list(parsed) + url_parts[4] = new_query + + new_url = urlunparse(url_parts) + tx.write({"return_url": new_url}) + + except (ValueError, KeyError): + pass + + # Identify Payment Mode + company_id = tx.acquirer_id.company_id.id + domain = [ + ("company_id", "=", company_id), + ("payment_type", "=", "inbound"), + ] + + payment_mode = False + + # Use token name to find matching mode + payment_brand = ( + token.name.split("_")[0] if token.name and "_" in token.name else token.name + ) + if payment_brand: + payment_mode = self.env["account.payment.mode"].search( + domain + [("name", "ilike", "%" + payment_brand + "%")], limit=1 + ) + + # Fallback to Acquirer's Journal (Standard Odoo Link) + if not payment_mode and tx.acquirer_id and tx.acquirer_id.journal_id: + payment_mode = self.env["account.payment.mode"].search( + domain + [("fixed_journal_id", "=", tx.acquirer_id.journal_id.id)], + limit=1, + ) + + if not payment_mode: + return self.browse(), _( + "Configuration Error: No suitable electronic payment mode found." + ) + + # Create the Group + vals = { + "partner_id": tx.partner_id.id, + "payment_mode_id": payment_mode.id, + "payment_token_id": token.id, + "recurring_unit": recurring_unit, + "recurring_value": recurring_value, + "active": True, + "ref": token.name, + } + + group = self.create(vals) + return { + "group": group, + "status": "new", + "message": _("Payment method successfully added."), + } diff --git a/my_compassion/models/contracts.py b/my_compassion/models/contracts.py index 7fc5173bd..36635e1a9 100644 --- a/my_compassion/models/contracts.py +++ b/my_compassion/models/contracts.py @@ -13,5 +13,30 @@ def _compute_can_show_on_my_compassion(self): for contract in self: contract.can_show_on_my_compassion = contract.state in [ "active", + "mandate", "terminated", ] or (contract.state != "cancelled" and not contract.parent_id) + + def change_contract_group(self, new_group_id): + """ + Moves the sponsorship (self) to the specified contract group. + :param new_group_id: int ID of the target recurring.contract.group + """ + self.ensure_one() + + if not new_group_id: + return False + + # If we are already in this group, do nothing + if self.group_id.id == new_group_id: + return True + + target_group = self.env["recurring.contract.group"].browse(new_group_id) + + if not target_group.exists() or target_group.partner_id != self.partner_id: + return False + + # Move the contract to the new group + self.write({"group_id": target_group.id}) + + return True diff --git a/my_compassion/models/res_partner.py b/my_compassion/models/res_partner.py index f39b504a2..66576d19b 100644 --- a/my_compassion/models/res_partner.py +++ b/my_compassion/models/res_partner.py @@ -53,7 +53,7 @@ def _compute_is_sponsor(self): "|", ("partner_id", "=", partner.id), ("correspondent_id", "=", partner.id), - ("state", "in", ["waiting", "active"]), + ("state", "in", ["waiting", "mandate", "active"]), ("child_id", "!=", False), ], ) @@ -93,3 +93,19 @@ def _compute_is_donor(self): donor_ids = {data["partner_id"][0] for data in donors_data} for partner in self: partner.is_donor = partner.id in donor_ids + + def get_payment_modes(self): + """ + Retrieve all unique payment modes currently linked to the partner. + Used to display existing methods. + """ + self.ensure_one() + # Find groups for this partner that have a payment mode. + groups = self.env["recurring.contract.group"].search( + [ + ("partner_id", "=", self.id), + ("payment_mode_id", "!=", False), + ] + ) + + return groups diff --git a/my_compassion/static/src/css/my2_payment_modal.css b/my_compassion/static/src/css/my2_payment_modal.css new file mode 100644 index 000000000..905dfe3fa --- /dev/null +++ b/my_compassion/static/src/css/my2_payment_modal.css @@ -0,0 +1,66 @@ +/* Define the variable for maintainability */ +:root { + /* Represents the total height of modal header/footer or vertical padding to subtract */ + --modal-vertical-padding: 64px; +} + +/* Card Container */ +.payment-method-card { + transition: all 0.2s ease-in-out; + border-width: 1px; +} + +/* Selected State */ +.payment-method-card.selected { + border-width: 10px; +} + +/* Hover State */ +.payment-method-card:not(.selected):hover { + background-color: #f8f9fa; + border-color: #dee2e6; +} + +.payment-method-card .pointer-events-none { + pointer-events: none; +} + +.payment-method-card.hover-shadow-sm:hover { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +#payment_method_selector_modal .modal-content { + max-height: 90vh; /* limit modal height */ + overflow: hidden; /* let inner container scroll */ +} + +/* scrollable list that fits inside modal (subtract header/footer space) */ +.payment-methods-list { + /* Using custom property to calculate available height */ + max-height: calc(100vh - var(--modal-vertical-padding)); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.oe_accordion_arrow { + transition: transform 0.25s ease-in-out; +} + +/* When button has .collapsed class → section is closed → show right arrow */ +.btn.collapsed .oe_accordion_arrow { + transform: rotate(-90deg); /* chevron-down becomes chevron-right */ +} + +/* When expanded → keep down (0deg) */ +.btn:not(.collapsed) .oe_accordion_arrow { + transform: rotate(0deg); +} + +/* Same logic for payment method cards*/ +.cursor-pointer[aria-expanded="false"] .oe_accordion_arrow { + transform: rotate(-90deg); +} + +.cursor-pointer[aria-expanded="true"] .oe_accordion_arrow { + transform: rotate(0deg); +} diff --git a/my_compassion/static/src/js/my2_donations.js b/my_compassion/static/src/js/my2_donations.js index 9c611f247..986bca277 100644 --- a/my_compassion/static/src/js/my2_donations.js +++ b/my_compassion/static/src/js/my2_donations.js @@ -1,54 +1,552 @@ -document.addEventListener("DOMContentLoaded", function () { - odoo.define("my_compassion.donations_pager_simple", function (require) { - "use strict"; +odoo.define("my_compassion.my2_donations", function (require) { + "use strict"; - var rpc = require("web.rpc"); - let isUpdating = false; + var publicWidget = require("web.public.widget"); + var core = require("web.core"); + var QWeb = core.qweb; + const ToastService = require("my_compassion.toast_service"); + var _t = core._t; - function updateHistory(page) { - if (isUpdating) { - return; + publicWidget.registry.My2Donations = publicWidget.Widget.extend({ + selector: ".my2-donations-page", + + // Load Client-Side QWeb Templates + xmlDependencies: ["/my_compassion/static/src/xml/my2_payment_method_templates.xml"], + + events: { + // Custom events dispatched from the DOM (e.g. from my2_sponsorships_group_card.xml) + open_payment_method_update: "_onOpenUpdateModal", + open_payment_method_change: "_onOpenChangeModal", + open_payment_method_add: "_onOpenAddModal", + + // UI Interaction events + "click #btn_save_payment_method": "_onSavePaymentMethod", + 'change input[name="payment_method_selection"]': "_onMethodSelectionChange", + "click #history_pager_prev, #history_pager_next": "_onPagerClick", + + "change #new_method_type": "_onAddMethodChange", + "click #postfinance-submit-btn": "_onSubmitPostFinance", + }, + + /** + * Widget Initialization + */ + start: function () { + var self = this; + + // 1. Initialize Local State + // Read the initial list of payment methods passed from the backend template + var $container = this.$("#my_sponsorships_container"); + this.payment_info_map = $container.data("payment-info-map") || []; + console.log("Initial Payment Info Map:", this.payment_info_map); + + // 2. Global Bindings (Cleanup & Modal behaviors) + this._onModalHiddenGlobalBound = this._onModalHiddenGlobal.bind(this); + this._onMethodSelectionChangeBound = this._onMethodSelectionChange.bind(this); + + $("body").on("hidden.bs.modal", ".modal", this._onModalHiddenGlobalBound); + // We bind change to body to catch inputs inside dynamically rendered modals + $("body").on("change", 'input[name="payment_method_selection"]', this._onMethodSelectionChangeBound); + + // 3. Check for URL flash messages (e.g. after redirects) + this._checkAddPaymentMethod(); + + return this._super.apply(this, arguments); + }, + + destroy: function () { + if (this._onModalHiddenGlobalBound) { + $("body").off("hidden.bs.modal", ".modal", this._onModalHiddenGlobalBound); + } + if (this._onMethodSelectionChangeBound) { + $("body").off("change", 'input[name="payment_method_selection"]', this._onMethodSelectionChangeBound); } + this._super.apply(this, arguments); + }, + + // PLEASE NOTE: + // Before deep diving into this code note that there's a documentation + // page explaining the overall architecture and flow of this code. + // https://compassion.odoo.com/odoo/knowledge/205 + + // ------------------------------------------------------------------------- + // MODAL OPEN HANDLERS + // ------------------------------------------------------------------------- + + /** + * Opens the "Change" modal (Moving a contract to a different group/method) + */ + _onOpenChangeModal: function (ev) { + ev.stopPropagation(); + // detail contains: { contract_id, group_id, child_name } passed via detail_js + var detail = ev.detail || {}; + var $container = this.$("#my_sponsorships_container"); + this.payment_info_map = $container.data("payment-info-map") || []; + var $modal = $("#payment_method_selector_modal_change"); + + // Update Description + $modal + .find("#modal_description") + .text( + _.str.sprintf( + _t("Change your payment method for %s."), + detail.child_name || _t("your sponsored child") + ) + ); + + // Store context + $modal.data("contract-id", detail.contract_id); + + // Render the list of available methods + // We pass the current group_id to highlight the currently active method + this._renderPaymentMethodsList($modal, detail.group_id, "#modal_container"); + + $modal.modal("show"); + }, + + /** + * Opens the "Update" modal (Editing the current group's details or merging) + */ + _onOpenUpdateModal: function (ev) { + ev.stopPropagation(); + var detail = ev.detail || {}; + // detail contains: { group_id, method_info } + + var $modal = $("#payment_method_selector_modal_update"); + $modal.data("group-id", detail.group_id); + + // 1. Render the "Update Current Details" Form + // We reuse the client-side QWeb template for the form + // Note: detail.method_info comes from the data-attributes we setup in the template + var formHtml = QWeb.render("my_compassion.PaymentMethodUpdateAccordion", detail.method_info || {}); + $modal.find("#modal_container").empty().html(formHtml); + + // 2. Render the "Switch" list + this._renderPaymentMethodsList($modal, detail.group_id, "#payment_methods_switch_container"); + + $modal.modal("show"); + }, + + /** + * Opens the "Add" modal and initializes Online Methods + */ + _onOpenAddModal: function (ev) { + if (ev) ev.stopPropagation(); + $("#payment_method_selector_modal_add").modal("show"); + + // Initialize PostFinance methods + this._fetchAndPopulateOnlineMethods(); + }, + + // ------------------------------------------------------------------------- + // RENDERING HELPER + // ------------------------------------------------------------------------- - const historyContainer = document.getElementById("donation_history_container"); - const pagerButtons = document.querySelectorAll("#history_pager_prev, #history_pager_next"); + /** + * Renders the list of payment method cards into a specific container. + * Optimized to render the whole list in one pass using QWeb. + */ + _renderPaymentMethodsList: function ($modal, currentGroupId, containerSelector) { + var $container = $modal.find(containerSelector); - if (!historyContainer) { - console.error("Donation history container not found."); + // Check if data exists (for Object/Map) + if (!this.payment_info_map || Object.keys(this.payment_info_map).length === 0) { + $container.html(QWeb.render("my_compassion.PaymentMethodLoading")); return; } - isUpdating = true; - pagerButtons.forEach((btn) => btn.classList.add("disabled")); + // 2. Render the entire list at once (Performance optimization) + // We pass the map and the 'currentGroupId' for the selected state logic + console.log("PAyment Methods:", this.payment_info_map); + var content = QWeb.render("my_compassion.PaymentMethodList", { + methods: this.payment_info_map, + current_group_id: parseInt(currentGroupId) || 0, + }); - rpc.query({ - route: "/my2/donations/history", + $container.html(content); + }, + + // ------------------------------------------------------------------------- + // ACTION HANDLERS + // ------------------------------------------------------------------------- + + _onMethodSelectionChange: function (ev) { + var $input = $(ev.currentTarget); + var $container = $input.closest(".payment-methods-container, #payment_methods_switch_container"); + + // Visual toggle of classes + $container + .find(".payment-method-card") + .removeClass("selected border-core-blue bg-light-blue") + .addClass("border-gray-200 hover-shadow-sm"); + + $input + .closest(".payment-method-card") + .addClass("selected border-core-blue bg-light-blue") + .removeClass("border-gray-200 hover-shadow-sm"); + }, + + _onSavePaymentMethod: function (ev) { + ev.preventDefault(); + var $btn = $(ev.currentTarget); + var $modal = $btn.closest(".modal"); + var modalType = $modal.data("modal-type"); + + var requestData = null; + + // 1. Delegate to specific strategy + if (modalType === "change") { + requestData = this._getChangeParams($modal); + } else if (modalType === "update") { + requestData = this._getUpdateParams($modal, $btn); + } else if (modalType === "add") { + requestData = this._getAddParams($modal); + } + + // 2. Execute if we got valid data back + if (requestData) { + this._executePaymentRequest($btn, $modal, requestData.route, requestData.params); + } + }, + + /** + * Change (Contract Level) + */ + _getChangeParams: function ($modal) { + var $selectedInput = $modal.find('input[name="payment_method_selection"]:checked'); + var new_group_id = $selectedInput.attr("group-id"); + + return { + route: "/my2/donation/change_method_contract", + params: { + contract_id: $modal.data("contract-id"), + group_id: parseInt(new_group_id), + }, + }; + }, + + /** + * Update (Group Level) + * Returns null if no changes were detected. + */ + _getUpdateParams: function ($modal, $btn) { + var currentGroupId = $modal.data("group-id"); + var params = { group_id: currentGroupId }; + + // Check for Group Switch (Merge) + var $selectedGroupInput = $modal.find('input[name="payment_method_selection"]:checked'); + if ($selectedGroupInput.length) { + var selectedGroupId = $selectedGroupInput.attr("group-id"); + if (selectedGroupId && parseInt(selectedGroupId) !== parseInt(currentGroupId)) { + params.new_group_id = parseInt(selectedGroupId); + } + } + + // Early Exit: No changes + if (!params.new_group_id && !params.new_bvr_ref) { + this._closeModal($modal, $btn); + return null; + } + + return { + route: "/my2/donation/change_method_group", + params: params, + }; + }, + + /** + * Add (New Method) + * Handles PostFinance special case internally. + */ + _getAddParams: function ($modal) { + // PostFinance Handler + if (this.pfHandler) { + this.pfHandler.validate(); + return null; // The handler takes over, no standard RPC needed here + } + + // Manual Methods + return { + route: "/my2/donation/add_payment_method_group", params: { - invoice_page: page, + method_type: $modal.find('select[name="method_type"]').val(), + recurring_unit: $modal.find('select[name="recurring_unit"]').val(), + advance_billing_months: parseInt($modal.find('input[name="advance_billing_months"]').val()), }, + }; + }, + + /** + * Shared Executor: Handles UI state, RPC call, HTML replacement, and Toast feedback. + */ + _executePaymentRequest: function ($btn, $modal, route, params) { + var self = this; + + // UI Loading State + $btn.prop("disabled", true).prepend(''); + + this._rpc({ + route: route, + params: params, }) .then(function (result) { - if (result.html) { - historyContainer.outerHTML = result.html; + if (result.success) { + $modal.modal("hide"); + ToastService.success(_t("The operation was successful."), _t("Success")); + + // Optimistic UI Update (Server-Side Rendered HTML) + if (result.html) { + var $newContent = $(result.html); + self.$("#my_sponsorships_container").replaceWith($newContent); + } + + // Update Client-Side Data State + if (result.payment_info_map) { + self.payment_info_map = result.payment_info_map; + } + } else { + ToastService.error(result.error || _t("An error occurred.")); } }) - .finally(() => { - isUpdating = false; - document.querySelectorAll("#history_pager_prev, #history_pager_next").forEach((btn) => { - if (btn) btn.classList.remove("disabled"); - }); + .finally(function () { + // Ensure button is reset even if we didn't reload + $btn.prop("disabled", false).find(".fa-spinner").remove(); }); - } - - document.addEventListener("click", function (event) { - const btn = event.target.closest("#history_pager_prev, #history_pager_next"); - if (btn) { - event.preventDefault(); - const page = btn.dataset.page; - if (page) { - updateHistory(page); + }, + + /** + * Utility: Helper to close modal cleanly + */ + _closeModal: function ($modal, $btn) { + $modal.modal("hide"); + if ($btn) { + $btn.prop("disabled", false).find(".fa-spinner").remove(); + } + }, + + /** + * Calls backend to create transaction and get available PostFinance methods. + */ + _fetchAndPopulateOnlineMethods: function () { + var self = this; + var $select = this.$("#new_method_type"); + + if ($select.data("loaded")) return; // Avoid duplicate calls + + var unit = this.$('select[name="recurring_unit"]').val() || "month"; + var val = this.$('input[name="advance_billing_months"]').val() || 1; + this._rpc({ + route: "/my2/donation/fetch_payment_methods_iframe", + params: { + recurring_unit: unit, + recurring_value: val, + }, + }).then(function (result) { + if (result.success && result.iframe_url && result.pf_methods) { + console.log("Received PostFinance methods:", result.pf_methods); + + // Load the JS library + $.getScript(result.iframe_url, function () { + console.log("PostFinance JS Loaded"); + }); + + // Append options directly to the main select + result.pf_methods.forEach(function (method) { + $select.append( + $("