# Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 import json import logging from werkzeug.urls import url_encode from odoo import _, api, fields, models from odoo.exceptions import ValidationError from odoo.tools.urls import urljoin as url_join from odoo.addons.fusion_poynt import const from odoo.addons.fusion_poynt import utils as poynt_utils from odoo.addons.fusion_poynt.controllers.main import PoyntController _logger = logging.getLogger(__name__) class PaymentTransaction(models.Model): _inherit = 'payment.transaction' poynt_order_id = fields.Char( string="Poynt Order ID", readonly=True, copy=False, ) poynt_transaction_id = fields.Char( string="Poynt Transaction ID", readonly=True, copy=False, ) poynt_receipt_data = fields.Text( string="Poynt Receipt Data", readonly=True, copy=False, help="JSON blob with receipt-relevant fields captured at payment time.", ) poynt_voided = fields.Boolean( string="Voided on Poynt", readonly=True, copy=False, default=False, ) poynt_void_date = fields.Datetime( string="Voided On", readonly=True, copy=False, ) def _get_provider_sudo(self): return self.provider_id.sudo() # === BUSINESS METHODS - PAYMENT FLOW === # def _get_specific_processing_values(self, processing_values): """Override of payment to return Poynt-specific processing values. 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. """ if self.provider_code != 'poynt': return super()._get_specific_processing_values(processing_values) if self.operation == 'online_token': return {} poynt_data = self._poynt_create_order_and_authorize() provider = self._get_provider_sudo() base_url = provider.get_base_url() return_url = url_join( base_url, f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}', ) return { 'poynt_order_id': poynt_data.get('order_id', ''), 'poynt_transaction_id': poynt_data.get('transaction_id', ''), 'return_url': return_url, 'business_id': provider.poynt_business_id, 'is_test': provider.state == 'test', } def _send_payment_request(self): """Override of `payment` to send a payment request to Poynt.""" if self.provider_code != 'poynt': return super()._send_payment_request() if self.operation in ('online_token', 'offline'): return self._poynt_process_token_payment() poynt_data = self._poynt_create_order_and_authorize() if poynt_data: payment_data = { 'reference': self.reference, 'poynt_order_id': poynt_data.get('order_id'), 'poynt_transaction_id': poynt_data.get('transaction_id'), 'poynt_status': poynt_data.get('status', 'AUTHORIZED'), 'funding_source': poynt_data.get('funding_source', {}), } self._process('poynt', payment_data) def _poynt_create_order_and_authorize(self): """Create a Poynt order and authorize the transaction. :return: Dict with order_id, transaction_id, status, and funding_source. :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 action = 'AUTHORIZE' if provider.capture_manually else 'SALE' txn_payload = poynt_utils.build_transaction_payload( action=action, amount=self.amount, currency=self.currency_id, order_id=order_id, reference=self.reference, business_id=provider.poynt_business_id, store_id=provider.poynt_store_id or '', ) txn_result = provider._poynt_make_request( 'POST', 'transactions', payload=txn_payload, ) transaction_id = txn_result.get('id', '') self.poynt_transaction_id = transaction_id self.provider_reference = transaction_id return { 'order_id': order_id, 'transaction_id': transaction_id, 'status': txn_result.get('status', ''), 'funding_source': txn_result.get('fundingSource', {}), } except ValidationError as e: self._set_error(str(e)) return {} def _poynt_process_token_payment(self): """Process a payment using a stored token (card on file). Uses the JWT payment token via POST /cards/tokenize/charge when available. Falls back to the legacy cardId flow for tokens that were created before the JWT migration. """ try: provider = self._get_provider_sudo() action = 'AUTHORIZE' if provider.capture_manually else 'SALE' payment_jwt = self.token_id.poynt_payment_token if payment_jwt: txn_result = provider._poynt_charge_token( payment_jwt=payment_jwt, amount=self.amount, currency=self.currency_id, action=action, reference=self.reference, ) else: funding_source = { 'type': 'CREDIT_DEBIT', 'card': { 'cardId': self.token_id.poynt_card_id, }, 'entryDetails': { 'customerPresenceStatus': 'MOTO', 'entryMode': 'KEYED', }, } 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 txn_payload = poynt_utils.build_transaction_payload( action=action, amount=self.amount, currency=self.currency_id, order_id=order_id, reference=self.reference, funding_source=funding_source, business_id=provider.poynt_business_id, store_id=provider.poynt_store_id or '', ) txn_result = provider._poynt_make_request( 'POST', 'transactions', payload=txn_payload, ) transaction_id = txn_result.get('id', '') self.poynt_transaction_id = transaction_id self.provider_reference = transaction_id order_id = txn_result.get('orderIdFromTransaction', '') or \ txn_result.get('orderId', '') or \ getattr(self, 'poynt_order_id', '') or '' if order_id: self.poynt_order_id = order_id payment_data = { 'reference': self.reference, 'poynt_order_id': order_id, 'poynt_transaction_id': transaction_id, 'poynt_status': txn_result.get('status', ''), 'funding_source': txn_result.get('fundingSource', {}), } self._process('poynt', payment_data) except ValidationError as e: self._set_error(str(e)) def _send_refund_request(self): """Override of `payment` to send a refund request to Poynt. For captured/settled transactions (SALE), we look up the CAPTURE child via HATEOAS links and use that as ``parentId``. The ``fundingSource`` is required per Poynt docs. """ if self.provider_code != 'poynt': return super()._send_refund_request() source_tx = self.source_transaction_id refund_amount = abs(self.amount) minor_amount = poynt_utils.format_poynt_amount(refund_amount, self.currency_id) parent_txn_id = source_tx.poynt_transaction_id or source_tx.provider_reference provider = self._get_provider_sudo() try: txn_data = provider._poynt_make_request( 'GET', f'transactions/{parent_txn_id}', ) for link in txn_data.get('links', []): if link.get('rel') == 'CAPTURE' and link.get('href'): parent_txn_id = link['href'] _logger.info( "Refund: using captureId %s instead of original %s", parent_txn_id, source_tx.poynt_transaction_id, ) break except (ValidationError, Exception): _logger.debug( "Could not fetch parent txn %s, using original ID", parent_txn_id, ) try: refund_payload = { 'action': 'REFUND', 'parentId': parent_txn_id, 'fundingSource': { 'type': 'CREDIT_DEBIT', }, 'amounts': { 'transactionAmount': minor_amount, 'orderAmount': minor_amount, 'currency': self.currency_id.name, }, 'context': { 'source': 'WEB', 'sourceApp': 'odoo.fusion_poynt', }, 'notes': f'Refund for {source_tx.reference}', } result = provider._poynt_make_request( 'POST', 'transactions', payload=refund_payload, ) self.provider_reference = result.get('id', '') self.poynt_transaction_id = result.get('id', '') payment_data = { 'reference': self.reference, 'poynt_transaction_id': result.get('id'), 'poynt_status': result.get('status', 'REFUNDED'), } self._process('poynt', payment_data) except ValidationError as e: self._set_error(str(e)) def _send_capture_request(self): """Override of `payment` to send a capture request to Poynt.""" if self.provider_code != 'poynt': return super()._send_capture_request() source_tx = self.source_transaction_id minor_amount = poynt_utils.format_poynt_amount(self.amount, self.currency_id) try: capture_payload = { 'action': 'CAPTURE', 'parentId': source_tx.provider_reference, 'amounts': { 'transactionAmount': minor_amount, 'orderAmount': minor_amount, 'currency': self.currency_id.name, }, 'context': { 'source': 'WEB', 'sourceApp': 'odoo.fusion_poynt', }, } result = self._get_provider_sudo()._poynt_make_request( 'POST', 'transactions', payload=capture_payload, ) payment_data = { 'reference': self.reference, 'poynt_transaction_id': result.get('id'), 'poynt_status': result.get('status', 'CAPTURED'), } self._process('poynt', payment_data) except ValidationError as e: self._set_error(str(e)) def _send_void_request(self): """Override of `payment` to send a void request to Poynt. Uses ``POST /transactions/{transactionId}/void`` -- the dedicated void endpoint. """ if self.provider_code != 'poynt': return super()._send_void_request() source_tx = self.source_transaction_id txn_id = source_tx.provider_reference or source_tx.poynt_transaction_id try: result = self._get_provider_sudo()._poynt_make_request( 'POST', f'transactions/{txn_id}/void', ) payment_data = { 'reference': self.reference, 'poynt_transaction_id': result.get('id', txn_id), 'poynt_status': result.get('status', 'VOIDED'), } self._process('poynt', payment_data) except ValidationError as e: self._set_error(str(e)) # === ACTION METHODS - VOID === # def action_poynt_void(self): """Void a confirmed Poynt transaction (same-day, before settlement). For SALE transactions Poynt creates an AUTHORIZE + CAPTURE pair. Voiding the AUTHORIZE after capture is rejected by the processor, so we first fetch the transaction, look for a CAPTURE child via the HATEOAS ``links``, and void that instead. On success the linked ``account.payment`` is cancelled (reversing invoice reconciliation) and the Odoo transaction is set to cancelled. No credit note is created because funds were never settled. """ self.ensure_one() if self.provider_code != 'poynt': raise ValidationError(_("This action is only available for Poynt transactions.")) if self.state != 'done': raise ValidationError(_("Only confirmed transactions can be voided.")) txn_id = self.poynt_transaction_id or self.provider_reference if not txn_id: raise ValidationError(_("No Poynt transaction ID found.")) existing_refund = self.env['payment.transaction'].sudo().search([ ('source_transaction_id', '=', self.id), ('operation', '=', 'refund'), ('state', '=', 'done'), ], limit=1) if existing_refund: raise ValidationError(_( "This transaction has already been refunded " "(%(ref)s). A voided transaction and a refund would " "result in a double reversal.", ref=existing_refund.reference, )) provider = self.sudo().provider_id txn_data = provider._poynt_make_request('GET', f'transactions/{txn_id}') poynt_status = txn_data.get('status', '') if poynt_status in ('REFUNDED', 'VOIDED') or txn_data.get('voided'): raise ValidationError(_( "This transaction has already been %(status)s on Poynt. " "It cannot be voided again.", status=poynt_status.lower() if poynt_status else 'voided', )) void_target_id = txn_id for link in txn_data.get('links', []): child_id = link.get('href', '') child_rel = link.get('rel', '') if not child_id: continue if child_rel == 'CAPTURE': void_target_id = child_id try: child_data = provider._poynt_make_request( 'GET', f'transactions/{child_id}', ) child_status = child_data.get('status', '') if child_status == 'REFUNDED' or child_data.get('voided'): raise ValidationError(_( "A linked transaction (%(child_id)s) has already " "been %(status)s. Voiding would cause a double " "reversal.", child_id=child_id, status='refunded' if child_status == 'REFUNDED' else 'voided', )) except ValidationError: raise except Exception: continue _logger.info( "Voiding Poynt transaction: original=%s, target=%s", txn_id, void_target_id, ) already_voided = txn_data.get('voided', False) if already_voided: _logger.info("Transaction %s is already voided on Poynt, skipping API call.", txn_id) result = txn_data else: result = provider._poynt_make_request( 'POST', f'transactions/{void_target_id}/void', ) _logger.info( "Poynt void response: status=%s, voided=%s, id=%s", result.get('status'), result.get('voided'), result.get('id'), ) is_voided = result.get('voided', False) void_status = result.get('status', '') if not is_voided and void_status not in ('VOIDED', 'REFUNDED'): if void_status == 'DECLINED': raise ValidationError(_( "Void declined by the payment processor. This usually " "means the batch has already settled (past the daily " "closeout at 6:00 PM). Settled transactions cannot be " "voided.\n\n" "To reverse this payment, create a Credit Note on the " "invoice and process a refund through the standard " "Odoo workflow." )) raise ValidationError( _("Poynt did not confirm the void. Status: %(status)s", status=void_status) ) if self.payment_id: self.payment_id.sudo().action_cancel() self.sudo().write({ 'state': 'cancel', 'poynt_voided': True, 'poynt_void_date': fields.Datetime.now(), }) invoice = self.invoice_ids[:1] if invoice: invoice.sudo().message_post( body=_( "Payment voided: transaction %(ref)s was voided on Poynt " "(Poynt void ID: %(void_id)s).", ref=self.reference, void_id=result.get('id', ''), ), ) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'type': 'success', 'message': _("Transaction voided successfully on Poynt."), 'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'}, }, } # === BUSINESS METHODS - NOTIFICATION PROCESSING === # @api.model def _search_by_reference(self, provider_code, payment_data): """Override of payment to find the transaction based on Poynt data.""" if provider_code != 'poynt': return super()._search_by_reference(provider_code, payment_data) reference = payment_data.get('reference') if reference: tx = self.search([ ('reference', '=', reference), ('provider_code', '=', 'poynt'), ]) else: poynt_txn_id = payment_data.get('poynt_transaction_id') if poynt_txn_id: tx = self.search([ ('poynt_transaction_id', '=', poynt_txn_id), ('provider_code', '=', 'poynt'), ]) else: _logger.warning("Received Poynt data with no reference or transaction ID") tx = self if not tx: _logger.warning( "No transaction found matching Poynt reference %s", reference, ) return tx def _extract_amount_data(self, payment_data): """Override of `payment` to skip amount validation for Poynt. Terminal payments may include tips or rounding adjustments, so we return None to opt out of the strict amount comparison. """ if self.provider_code != 'poynt': return super()._extract_amount_data(payment_data) return None def _apply_updates(self, payment_data): """Override of `payment` to update the transaction based on Poynt data.""" if self.provider_code != 'poynt': return super()._apply_updates(payment_data) poynt_txn_id = payment_data.get('poynt_transaction_id') if poynt_txn_id: self.provider_reference = poynt_txn_id self.poynt_transaction_id = poynt_txn_id poynt_order_id = payment_data.get('poynt_order_id') if poynt_order_id: self.poynt_order_id = poynt_order_id funding_source = payment_data.get('funding_source', {}) if funding_source: card_details = poynt_utils.extract_card_details(funding_source) if card_details.get('brand'): payment_method = self.env['payment.method']._get_from_code( card_details['brand'], mapping=const.CARD_BRAND_MAPPING, ) if payment_method: self.payment_method_id = payment_method status = payment_data.get('poynt_status', '') if not status: self._set_error(_("Received data with missing transaction status.")) return odoo_state = poynt_utils.get_poynt_status(status) if odoo_state == 'authorized': self._set_authorized() elif odoo_state == 'done': self._set_done() self._post_process() self._poynt_generate_receipt(payment_data) elif odoo_state == 'cancel': self._set_canceled() elif odoo_state == 'refund': self._set_done() self._post_process() self._poynt_generate_receipt(payment_data) elif odoo_state == 'error': error_msg = payment_data.get('error_message', _("Payment was declined by Poynt.")) self._set_error(error_msg) else: _logger.warning( "Received unknown Poynt status (%s) for transaction %s.", status, self.reference, ) self._set_error( _("Received data with unrecognized status: %s.", status) ) def _create_payment(self, **extra_create_values): """Override to route Poynt payments directly to the bank account. Card payments via Poynt are settled instantly -- there is no separate bank reconciliation step. We swap the ``outstanding_account_id`` to the journal's default (bank) account before posting so the payment transitions to ``paid`` instead of lingering in ``in_process``. """ if self.provider_code != 'poynt': return super()._create_payment(**extra_create_values) self.ensure_one() provider = self._get_provider_sudo() reference = f'{self.reference} - {self.provider_reference or ""}' payment_method_line = provider.journal_id.inbound_payment_method_line_ids\ .filtered(lambda l: l.payment_provider_id == provider) payment_values = { 'amount': abs(self.amount), 'payment_type': 'inbound' if self.amount > 0 else 'outbound', 'currency_id': self.currency_id.id, 'partner_id': self.partner_id.commercial_partner_id.id, 'partner_type': 'customer', 'journal_id': provider.journal_id.id, 'company_id': provider.company_id.id, 'payment_method_line_id': payment_method_line.id, 'payment_token_id': self.token_id.id, 'payment_transaction_id': self.id, 'memo': reference, 'write_off_line_vals': [], 'invoice_ids': self.invoice_ids, **extra_create_values, } payment_term_lines = self.invoice_ids.line_ids.filtered( lambda line: line.display_type == 'payment_term' ) if payment_term_lines: payment_values['destination_account_id'] = payment_term_lines[0].account_id.id payment = self.env['account.payment'].create(payment_values) bank_account = provider.journal_id.default_account_id if bank_account and bank_account.account_type == 'asset_cash': payment.outstanding_account_id = bank_account payment.action_post() self.payment_id = payment if self.operation == self.source_transaction_id.operation: invoices = self.source_transaction_id.invoice_ids else: invoices = self.invoice_ids invoices = invoices.filtered(lambda inv: inv.state != 'cancel') if invoices: invoices.filtered(lambda inv: inv.state == 'draft').action_post() (payment.move_id.line_ids + invoices.line_ids).filtered( lambda line: line.account_id == payment.destination_account_id and not line.reconciled ).reconcile() return payment def _extract_token_values(self, payment_data): """Override of `payment` to return token data based on Poynt data.""" if self.provider_code != 'poynt': return super()._extract_token_values(payment_data) funding_source = payment_data.get('funding_source', {}) card_details = poynt_utils.extract_card_details(funding_source) if not card_details: _logger.warning( "Tokenization requested but no card data in payment response." ) return {} return { 'payment_details': card_details.get('last4', ''), 'poynt_card_id': card_details.get('card_id', ''), } # === RECEIPT GENERATION === # def _poynt_generate_receipt(self, payment_data=None): """Fetch transaction details from Poynt, store receipt data, generate a PDF receipt, and attach it to the linked invoice's chatter. This method is best-effort: failures are logged but never block the payment flow. """ self.ensure_one() if self.provider_code != 'poynt' or not self.poynt_transaction_id: return try: self._poynt_store_receipt_data(payment_data) self._poynt_attach_receipt_pdf() self._poynt_attach_poynt_receipt() except Exception: _logger.exception( "Receipt generation failed for transaction %s", self.reference, ) def _poynt_store_receipt_data(self, payment_data=None): """Fetch the full Poynt transaction and persist receipt-relevant fields in :attr:`poynt_receipt_data` as a JSON blob.""" txn_data = {} try: txn_data = self._get_provider_sudo()._poynt_make_request( 'GET', f'transactions/{self.poynt_transaction_id}', ) except (ValidationError, Exception): _logger.debug( "Could not fetch Poynt txn %s for receipt", self.poynt_transaction_id, ) funding = payment_data.get('funding_source', {}) if payment_data else {} if not funding: funding = txn_data.get('fundingSource', {}) card = funding.get('card', {}) entry = funding.get('entryDetails', {}) amounts = txn_data.get('amounts', {}) processor = txn_data.get('processorResponse', {}) context = txn_data.get('context', {}) currency_name = amounts.get('currency', self.currency_id.name) decimals = const.CURRENCY_DECIMALS.get(currency_name, 2) receipt = { 'transaction_id': self.poynt_transaction_id, 'order_id': self.poynt_order_id or '', 'reference': self.reference, 'status': txn_data.get('status', payment_data.get('poynt_status', '') if payment_data else ''), 'created_at': txn_data.get('createdAt', ''), 'card_type': card.get('type', ''), 'card_last4': card.get('numberLast4', ''), 'card_first6': card.get('numberFirst6', ''), 'card_holder': card.get('cardHolderFullName', ''), 'entry_mode': entry.get('entryMode', ''), 'customer_presence': entry.get('customerPresenceStatus', ''), 'transaction_amount': amounts.get('transactionAmount', 0) / (10 ** decimals) if amounts.get('transactionAmount') else float(self.amount), 'tip_amount': amounts.get('tipAmount', 0) / (10 ** decimals) if amounts.get('tipAmount') else 0, 'currency': currency_name, 'approval_code': processor.get('approvalCode', txn_data.get('approvalCode', '')), 'processor': processor.get('processor', ''), 'processor_status': processor.get('status', ''), 'store_id': context.get('storeId', ''), 'device_id': context.get('storeDeviceId', ''), } self.poynt_receipt_data = json.dumps(receipt) def _poynt_attach_receipt_pdf(self): """Render the QWeb receipt report and attach the PDF to the invoice.""" invoice = self.invoice_ids[:1] if not invoice: return try: report = self.env.ref('fusion_poynt.action_report_poynt_receipt') pdf_content, _report_type = report._render_qweb_pdf(report.report_name, [self.id]) except Exception: _logger.debug("Could not render Poynt receipt PDF for %s", self.reference) return filename = f"Payment_Receipt_{self.reference}.pdf" attachment = self.env['ir.attachment'].sudo().create({ 'name': filename, 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'account.move', 'res_id': invoice.id, 'mimetype': 'application/pdf', }) invoice.sudo().message_post( body=_( "Payment receipt generated for transaction %(ref)s.", ref=self.reference, ), attachment_ids=[attachment.id], ) def _poynt_attach_poynt_receipt(self): """Try the Poynt renderReceipt endpoint and attach the result.""" invoice = self.invoice_ids[:1] if not invoice: return receipt_content = self._get_provider_sudo()._poynt_fetch_receipt( self.poynt_transaction_id, ) if not receipt_content: return filename = f"Poynt_Receipt_{self.reference}.html" self.env['ir.attachment'].sudo().create({ 'name': filename, 'type': 'binary', 'datas': base64.b64encode(receipt_content.encode('utf-8')), 'res_model': 'account.move', 'res_id': invoice.id, 'mimetype': 'text/html', }) def _get_poynt_receipt_values(self): """Parse the stored receipt JSON for use in QWeb templates. For refund transactions that lack their own receipt data, this falls back to the source (sale) transaction's receipt data so the card details and approval codes are still available. :return: Dict of receipt values or empty dict. :rtype: dict """ self.ensure_one() data = self.poynt_receipt_data if not data and self.source_transaction_id: data = self.source_transaction_id.poynt_receipt_data if not data: return {} try: return json.loads(data) except (json.JSONDecodeError, TypeError): return {} def _get_source_receipt_values(self): """Return receipt values from the original sale transaction. Used by the refund receipt template to render the original sale details on a second page. """ self.ensure_one() if self.source_transaction_id and self.source_transaction_id.poynt_receipt_data: try: return json.loads(self.source_transaction_id.poynt_receipt_data) except (json.JSONDecodeError, TypeError): pass return {}