From 2563208f53006cc2b346ca17156a1c9b991b3195 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 26 Mar 2026 15:16:51 -0400 Subject: [PATCH] changes --- fusion_poynt/controllers/main.py | 33 +++++-- fusion_poynt/models/account_move.py | 31 ++++++ fusion_poynt/models/payment_transaction.py | 95 ++++++++++++++++++- fusion_poynt/models/sale_order.py | 33 ++++++- .../static/src/interactions/payment_form.js | 61 ++++++++++++ fusion_poynt/utils.py | 2 + fusion_poynt/views/account_move_views.xml | 9 +- .../views/payment_poynt_templates.xml | 56 +++++++++++ .../views/payment_transaction_views.xml | 8 +- fusion_poynt/views/sale_order_views.xml | 12 +++ fusion_poynt/wizard/poynt_payment_wizard.py | 1 + fusion_poynt/wizard/poynt_refund_wizard.py | 1 + fusion_rental/security/ir.model.access.csv | 2 +- 13 files changed, 329 insertions(+), 15 deletions(-) diff --git a/fusion_poynt/controllers/main.py b/fusion_poynt/controllers/main.py index 38c31b3f..fcb640a0 100644 --- a/fusion_poynt/controllers/main.py +++ b/fusion_poynt/controllers/main.py @@ -467,7 +467,9 @@ class PoyntController(http.Controller): def poynt_process_card(self, reference=None, poynt_order_id=None, card_number=None, exp_month=None, exp_year=None, cvv=None, cardholder_name=None, card_type=None, - **kwargs): + billing_address=None, billing_city=None, + billing_state=None, billing_zip=None, + billing_country=None, **kwargs): """Process a card payment through Poynt Cloud API. The frontend sends card details which are passed to Poynt for @@ -503,6 +505,13 @@ class PoyntController(http.Controller): }, 'verificationData': { 'cvData': cvv, + 'cardHolderBillingAddress': { + 'line1': billing_address or '', + 'city': billing_city or '', + 'territory': billing_state or '', + 'postalCode': billing_zip or '', + 'countryCode': billing_country or '', + }, }, 'entryDetails': { 'customerPresenceStatus': 'ECOMMERCE', @@ -510,13 +519,25 @@ class PoyntController(http.Controller): }, } - action = 'AUTHORIZE' if tx_sudo.provider_id.capture_manually else 'SALE' + provider = tx_sudo.provider_id.sudo() + action = 'AUTHORIZE' if provider.capture_manually else 'SALE' minor_amount = poynt_utils.format_poynt_amount( tx_sudo.amount, tx_sudo.currency_id, ) + context = { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', + } + if provider.poynt_business_id: + context['businessId'] = provider.poynt_business_id + if provider.poynt_store_id: + context['storeId'] = provider.poynt_store_id + txn_payload = { 'action': action, + 'fundingSourceType': 'CREDIT_DEBIT', 'amounts': { 'transactionAmount': minor_amount, 'orderAmount': minor_amount, @@ -525,11 +546,7 @@ class PoyntController(http.Controller): 'currency': tx_sudo.currency_id.name, }, 'fundingSource': funding_source, - 'context': { - 'source': 'WEB', - 'sourceApp': 'odoo.fusion_poynt', - 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', - }, + 'context': context, 'notes': reference, } @@ -539,7 +556,7 @@ class PoyntController(http.Controller): 'type': 'POYNT_ORDER', }] - result = tx_sudo.provider_id._poynt_make_request( + result = provider._poynt_make_request( 'POST', 'transactions', payload=txn_payload, ) diff --git a/fusion_poynt/models/account_move.py b/fusion_poynt/models/account_move.py index 4d8ed74f..9f0333a5 100644 --- a/fusion_poynt/models/account_move.py +++ b/fusion_poynt/models/account_move.py @@ -23,6 +23,10 @@ class AccountMove(models.Model): string="Has Poynt Receipt", compute='_compute_has_poynt_receipt', ) + poynt_transaction_count = fields.Integer( + string="Poynt Transactions", + compute='_compute_poynt_transaction_count', + ) @api.depends('reversal_move_ids') def _compute_poynt_refund_count(self): @@ -38,6 +42,33 @@ class AccountMove(models.Model): for move in self: move.has_poynt_receipt = bool(move._get_poynt_transaction_for_receipt()) + def _compute_poynt_transaction_count(self): + for move in self: + move.poynt_transaction_count = self.env['payment.transaction'].sudo().search_count([ + ('invoice_ids', 'in', move.id), + ('provider_code', '=', 'poynt'), + ]) + + def action_view_poynt_transactions(self): + """Open payment transactions linked to this invoice/credit note.""" + self.ensure_one() + transactions = self.env['payment.transaction'].sudo().search([ + ('invoice_ids', 'in', self.id), + ('provider_code', '=', 'poynt'), + ]) + action = { + 'name': _("Poynt Transactions"), + 'type': 'ir.actions.act_window', + 'res_model': 'payment.transaction', + 'domain': [('id', 'in', transactions.ids)], + } + if len(transactions) == 1: + action['view_mode'] = 'form' + action['res_id'] = transactions.id + else: + action['view_mode'] = 'list,form' + return action + def action_view_poynt_refunds(self): """Open the credit notes linked to this invoice that were refunded via Poynt.""" self.ensure_one() diff --git a/fusion_poynt/models/payment_transaction.py b/fusion_poynt/models/payment_transaction.py index 6c57d01f..52dbd30c 100644 --- a/fusion_poynt/models/payment_transaction.py +++ b/fusion_poynt/models/payment_transaction.py @@ -58,6 +58,8 @@ class PaymentTransaction(models.Model): For direct (online) payments we create a Poynt order upfront and return identifiers plus the return URL so the frontend JS can complete the flow. + The actual transaction is created later when the frontend sends card + details via the /payment/poynt/process_card route. """ if self.provider_code != 'poynt': return super()._get_specific_processing_values(processing_values) @@ -65,7 +67,7 @@ class PaymentTransaction(models.Model): if self.operation == 'online_token': return {} - poynt_data = self._poynt_create_order_and_authorize() + order_data = self._poynt_create_order() provider = self._get_provider_sudo() base_url = provider.get_base_url() @@ -75,8 +77,7 @@ class PaymentTransaction(models.Model): ) return { - 'poynt_order_id': poynt_data.get('order_id', ''), - 'poynt_transaction_id': poynt_data.get('transaction_id', ''), + 'poynt_order_id': order_data.get('order_id', ''), 'return_url': return_url, 'business_id': provider.poynt_business_id, 'is_test': provider.state == 'test', @@ -108,6 +109,33 @@ class PaymentTransaction(models.Model): return self._process('poynt', payment_data) + def _poynt_create_order(self): + """Create a Poynt order without a transaction. + + Used by the portal payment flow where card details are collected + on the frontend and the transaction is created separately via + the /payment/poynt/process_card route. + + :return: Dict with order_id. + :rtype: dict + """ + try: + provider = self._get_provider_sudo() + order_payload = poynt_utils.build_order_payload( + self.reference, self.amount, self.currency_id, + business_id=provider.poynt_business_id, + store_id=provider.poynt_store_id or '', + ) + order_result = provider._poynt_make_request( + 'POST', 'orders', payload=order_payload, + ) + order_id = order_result.get('id', '') + self.poynt_order_id = order_id + return {'order_id': order_id} + except ValidationError as e: + self._set_error(str(e)) + return {} + def _poynt_create_order_and_authorize(self): """Create a Poynt order and authorize the transaction. @@ -383,6 +411,7 @@ class PaymentTransaction(models.Model): try: refund_payload = { 'action': 'REFUND', + 'fundingSourceType': 'CREDIT_DEBIT', 'parentId': parent_txn_id, 'fundingSource': { 'type': 'CREDIT_DEBIT', @@ -426,6 +455,7 @@ class PaymentTransaction(models.Model): try: capture_payload = { 'action': 'CAPTURE', + 'fundingSourceType': 'CREDIT_DEBIT', 'parentId': source_tx.provider_reference, 'amounts': { 'transactionAmount': minor_amount, @@ -817,6 +847,7 @@ class PaymentTransaction(models.Model): self._poynt_store_receipt_data(payment_data) self._poynt_attach_receipt_pdf() self._poynt_attach_poynt_receipt() + self._poynt_auto_send_invoice_and_receipt() except Exception: _logger.exception( "Receipt generation failed for transaction %s", self.reference, @@ -925,6 +956,64 @@ class PaymentTransaction(models.Model): 'mimetype': 'text/html', }) + def _poynt_auto_send_invoice_and_receipt(self): + """Automatically email the invoice and payment receipt to the customer + after a successful payment. + + 1. Sends the invoice via the standard Odoo invoice email template. + 2. Sends the Poynt payment receipt email with the PDF attached. + + Best-effort: failures are logged but never block the payment flow. + """ + self.ensure_one() + invoice = self.invoice_ids[:1] + partner = self.partner_id + + if not partner.email: + _logger.info( + "Skipping auto-send for %s: partner %s has no email.", + self.reference, partner.display_name, + ) + return + + # 1. Send the invoice PDF + if invoice and invoice.state == 'posted': + try: + inv_template = self.env.ref( + 'account.email_template_edi_invoice', + raise_if_not_found=False, + ) + if inv_template: + inv_template.sudo().send_mail( + invoice.id, force_send=True, + ) + invoice.sudo().write({'is_move_sent': True}) + _logger.info( + "Auto-sent invoice %s to %s", + invoice.name, partner.email, + ) + except Exception: + _logger.exception( + "Failed to auto-send invoice %s", invoice.name, + ) + + # 2. Send the payment receipt + try: + receipt_template = self.env.ref( + 'fusion_poynt.mail_template_poynt_receipt', + raise_if_not_found=False, + ) + if receipt_template: + receipt_template.sudo().send_mail(self.id, force_send=True) + _logger.info( + "Auto-sent payment receipt for %s to %s", + self.reference, partner.email, + ) + except Exception: + _logger.exception( + "Failed to auto-send receipt for %s", self.reference, + ) + def _get_poynt_receipt_values(self): """Parse the stored receipt JSON for use in QWeb templates. diff --git a/fusion_poynt/models/sale_order.py b/fusion_poynt/models/sale_order.py index 4f7b2860..fda86651 100644 --- a/fusion_poynt/models/sale_order.py +++ b/fusion_poynt/models/sale_order.py @@ -2,7 +2,7 @@ import logging -from odoo import _, models +from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) @@ -11,6 +11,37 @@ _logger = logging.getLogger(__name__) class SaleOrder(models.Model): _inherit = 'sale.order' + poynt_transaction_count = fields.Integer( + string="Poynt Transactions", + compute='_compute_poynt_transaction_count', + ) + + def _compute_poynt_transaction_count(self): + for order in self: + order.poynt_transaction_count = self.env['payment.transaction'].sudo().search_count([ + ('sale_order_ids', 'in', order.id), + ('provider_code', '=', 'poynt'), + ]) + + def action_view_poynt_transactions(self): + self.ensure_one() + transactions = self.env['payment.transaction'].sudo().search([ + ('sale_order_ids', 'in', self.id), + ('provider_code', '=', 'poynt'), + ]) + action = { + 'name': _("Poynt Transactions"), + 'type': 'ir.actions.act_window', + 'res_model': 'payment.transaction', + 'domain': [('id', 'in', transactions.ids)], + } + if len(transactions) == 1: + action['view_mode'] = 'form' + action['res_id'] = transactions.id + else: + action['view_mode'] = 'list,form' + return action + def action_poynt_collect_payment(self): """Create an invoice (if needed) and open the Poynt payment wizard. diff --git a/fusion_poynt/static/src/interactions/payment_form.js b/fusion_poynt/static/src/interactions/payment_form.js index 1701fb26..fb113ed5 100644 --- a/fusion_poynt/static/src/interactions/payment_form.js +++ b/fusion_poynt/static/src/interactions/payment_form.js @@ -45,6 +45,22 @@ patch(PaymentForm.prototype, { this._setupCardFormatting(poyntContainer); this._setupTerminalToggle(poyntContainer); this._setupSurcharge(poyntContainer); + this._prefillBillingAddress(poyntContainer); + }, + + _prefillBillingAddress(container) { + const billing = this.poyntFormData.billing_details; + if (!billing || !billing.address) return; + const addr = billing.address; + const setVal = (id, val) => { + const el = container.querySelector(id); + if (el && val) el.value = val; + }; + setVal('#poynt_billing_address', addr.line1); + setVal('#poynt_billing_city', addr.city); + setVal('#poynt_billing_state', addr.state); + setVal('#poynt_billing_zip', addr.postal_code); + setVal('#poynt_billing_country', addr.country); }, _detectCardBrand(number) { @@ -317,6 +333,36 @@ patch(PaymentForm.prototype, { return checked ? checked.value : 'other'; }, + _showProcessingOverlay(container) { + const overlay = container.querySelector('.o_poynt_processing_overlay'); + if (overlay) { + // Hide all form field sections + Array.from(container.children).forEach(child => { + if (!child.classList.contains('o_poynt_processing_overlay')) { + child.style.display = 'none'; + } + }); + overlay.style.display = 'block'; + } + }, + + _hideProcessingOverlay(container) { + const overlay = container.querySelector('.o_poynt_processing_overlay'); + if (overlay) { + overlay.style.display = 'none'; + Array.from(container.children).forEach(child => { + if (!child.classList.contains('o_poynt_processing_overlay')) { + child.style.display = ''; + } + }); + } + }, + + _updateProcessingMessage(container, message) { + const msgEl = container.querySelector('.o_poynt_processing_message'); + if (msgEl) msgEl.textContent = message; + }, + async _processCardPayment(processingValues, inlineForm) { const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, ''); const expiry = inlineForm.querySelector('#poynt_expiry').value; @@ -328,6 +374,13 @@ patch(PaymentForm.prototype, { const [expMonth, expYear] = expiry.split('/').map(Number); + const formContainer = inlineForm.closest('.o_poynt_payment_form') + || inlineForm.querySelector('.o_poynt_payment_form') + || inlineForm; + + // Show processing animation + this._showProcessingOverlay(formContainer); + try { const result = await rpc('/payment/poynt/process_card', { reference: processingValues.reference, @@ -338,9 +391,15 @@ patch(PaymentForm.prototype, { cvv: cvv, cardholder_name: cardholder, card_type: cardType, + billing_address: inlineForm.querySelector('#poynt_billing_address')?.value || '', + billing_city: inlineForm.querySelector('#poynt_billing_city')?.value || '', + billing_state: inlineForm.querySelector('#poynt_billing_state')?.value || '', + billing_zip: inlineForm.querySelector('#poynt_billing_zip')?.value || '', + billing_country: inlineForm.querySelector('#poynt_billing_country')?.value || '', }); if (result.error) { + this._hideProcessingOverlay(formContainer); this._displayErrorDialog( _t("Payment Failed"), result.error, @@ -349,8 +408,10 @@ patch(PaymentForm.prototype, { return; } + this._updateProcessingMessage(formContainer, _t("Payment successful! Redirecting...")); window.location.href = processingValues.return_url; } catch (error) { + this._hideProcessingOverlay(formContainer); this._displayErrorDialog( _t("Payment Processing Error"), error.message || _t("An unexpected error occurred."), diff --git a/fusion_poynt/utils.py b/fusion_poynt/utils.py index 9c65e1bd..542cba8f 100644 --- a/fusion_poynt/utils.py +++ b/fusion_poynt/utils.py @@ -269,6 +269,7 @@ def build_transaction_payload( payload = { 'action': action, + 'fundingSourceType': 'CREDIT_DEBIT', 'amounts': { 'transactionAmount': minor_amount, 'orderAmount': minor_amount, @@ -318,6 +319,7 @@ def build_token_charge_payload( payload = { 'action': action, + 'fundingSourceType': 'CREDIT_DEBIT', 'context': context, 'amounts': { 'transactionAmount': minor_amount, diff --git a/fusion_poynt/views/account_move_views.xml b/fusion_poynt/views/account_move_views.xml index 95f22aff..837cbb0d 100644 --- a/fusion_poynt/views/account_move_views.xml +++ b/fusion_poynt/views/account_move_views.xml @@ -8,8 +8,15 @@ 60 - + + + + +