# 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_clover import const from odoo.addons.fusion_clover import utils as clover_utils from odoo.addons.fusion_clover.controllers.main import CloverController _logger = logging.getLogger(__name__) class PaymentTransaction(models.Model): _inherit = 'payment.transaction' clover_charge_id = fields.Char( string="Clover Charge ID", readonly=True, copy=False, ) clover_refund_id = fields.Char( string="Clover Refund ID", readonly=True, copy=False, ) clover_receipt_data = fields.Text( string="Clover Receipt Data", readonly=True, copy=False, help="JSON blob with receipt-relevant fields captured at payment time.", ) clover_order_id = fields.Char( string="Clover Order ID", readonly=True, copy=False, ) clover_voided = fields.Boolean( string="Voided", default=False, copy=False, ) clover_void_date = fields.Datetime( string="Void Date", 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 Clover-specific processing values.""" if self.provider_code != 'clover': return super()._get_specific_processing_values(processing_values) if self.operation == 'online_token': return {} provider = self._get_provider_sudo() base_url = provider.get_base_url() return_url = url_join( base_url, f'{CloverController._return_url}?{url_encode({"reference": self.reference})}', ) return { 'return_url': return_url, 'merchant_id': provider.clover_merchant_id, 'is_test': provider.state == 'test', } def _send_payment_request(self): """Override of `payment` to send a payment request to Clover.""" if self.provider_code != 'clover': return super()._send_payment_request() if self.operation in ('online_token', 'offline'): return self._clover_process_token_payment() @staticmethod def _detect_card_brand_from_details(payment_details): """Detect card brand from the payment_details string on a token.""" details = (payment_details or '').upper() if 'AMEX' in details or 'AMERICAN_EXPRESS' in details: return 'amex' if 'VISA' in details: return 'visa' if 'MASTER' in details: return 'mastercard' return 'other' def _apply_token_surcharge(self): """Apply surcharge to the linked invoice for token-based payments.""" ICP = self.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clover.surcharge_enabled', 'False') != 'True': return if not self.token_id or not self.invoice_ids: return for inv in self.invoice_ids: sale_orders = inv.mapped('line_ids.sale_line_ids.order_id') for so in sale_orders: if getattr(so, 'is_rental_order', False): if not getattr(so, 'rental_apply_cc_fee', True): return card_type = self._detect_card_brand_from_details( self.token_id.payment_details, ) rate_key = { 'visa': 'fusion_clover.surcharge_visa_rate', 'mastercard': 'fusion_clover.surcharge_mastercard_rate', 'amex': 'fusion_clover.surcharge_amex_rate', 'debit': 'fusion_clover.surcharge_debit_rate', }.get(card_type, 'fusion_clover.surcharge_other_rate') rate = float(ICP.get_param(rate_key, '0') or 0) if rate <= 0: return product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0) product = self.env['product.product'].sudo().browse(product_id).exists() if not product: product = self.env.ref( 'fusion_clover.product_cc_processing_fee', raise_if_not_found=False, ) if not product: _logger.warning("Surcharge product not configured; skipping token surcharge") return total_fee = 0.0 for invoice in self.invoice_ids.sudo(): already_has = invoice.invoice_line_ids.filtered( lambda l: l.product_id.id == product.id ) if already_has: continue fee_amount = round(invoice.amount_residual * rate / 100.0, 2) if fee_amount <= 0: continue was_posted = invoice.state == 'posted' if was_posted: invoice.button_draft() description = "Credit Card Processing Fee (%.2f%% surcharge)" % rate invoice.write({ 'invoice_line_ids': [(0, 0, { 'product_id': product.id, 'name': description, 'quantity': 1, 'price_unit': fee_amount, 'tax_ids': [(5, 0, 0)], })], }) if was_posted: invoice.action_post() total_fee += fee_amount if total_fee > 0: self.amount += total_fee def _clover_process_token_payment(self): """Process a payment using a stored token (card on file).""" try: self._apply_token_surcharge() provider = self._get_provider_sudo() capture = not provider.capture_manually clover_token = self.token_id.clover_source_token if not clover_token: self._set_error(_("No Clover token found for this saved card.")) return result = provider._clover_create_charge( source_token=clover_token, amount=self.amount, currency=self.currency_id, capture=capture, description=self.reference, ecomind='moto', metadata={'odoo_reference': self.reference}, ) charge_id = result.get('id', '') status = result.get('status', '') self.clover_charge_id = charge_id self.provider_reference = charge_id payment_data = { 'reference': self.reference, 'clover_charge_id': charge_id, 'clover_status': status, 'source': result.get('source', {}), } if status == 'failed': outcome = result.get('outcome', {}) decline_msg = outcome.get('type', status) self._set_error( _("Payment %(status)s: %(reason)s", status=status, reason=decline_msg) ) return self._process('clover', 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 Clover.""" if self.provider_code != 'clover': return super()._send_refund_request() source_tx = self.source_transaction_id charge_id = source_tx.clover_charge_id or source_tx.provider_reference refund_amount = abs(self.amount) try: result = self._get_provider_sudo()._clover_create_refund( charge_id=charge_id, amount=refund_amount, currency=self.currency_id, reason=f'Refund for {source_tx.reference}', ) refund_id = result.get('id', '') self.provider_reference = refund_id self.clover_refund_id = refund_id payment_data = { 'reference': self.reference, 'clover_charge_id': charge_id, 'clover_refund_id': refund_id, 'clover_status': result.get('status', 'succeeded'), } self._process('clover', 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 Clover.""" if self.provider_code != 'clover': return super()._send_capture_request() source_tx = self.source_transaction_id charge_id = source_tx.clover_charge_id or source_tx.provider_reference try: result = self._get_provider_sudo()._clover_capture_charge( charge_id=charge_id, amount=self.amount, currency=self.currency_id, ) payment_data = { 'reference': self.reference, 'clover_charge_id': result.get('id', charge_id), 'clover_status': result.get('status', 'succeeded'), } self._process('clover', payment_data) except ValidationError as e: self._set_error(str(e)) def _send_void_request(self): """Override of `payment` to send a void (refund full) request to Clover. Clover doesn't have a dedicated void endpoint -- a full refund before settlement acts as a void. """ if self.provider_code != 'clover': return super()._send_void_request() source_tx = self.source_transaction_id charge_id = source_tx.clover_charge_id or source_tx.provider_reference try: result = self._get_provider_sudo()._clover_create_refund( charge_id=charge_id, reason=f'Void for {source_tx.reference}', ) payment_data = { 'reference': self.reference, 'clover_charge_id': charge_id, 'clover_refund_id': result.get('id', ''), 'clover_status': result.get('status', 'succeeded'), } self._process('clover', payment_data) except ValidationError as e: self._set_error(str(e)) # === ACTION METHODS - VOID === # def action_clover_void(self): """Void a confirmed Clover transaction (same-day, before settlement). Clover's Ecommerce API treats a full refund on an unsettled charge as a void. We issue ``POST /v1/refunds`` for the full amount; if the charge has already settled, the processor will decline the void (the user should create a credit note and use the refund wizard instead). """ self.ensure_one() if self.provider_code != 'clover': raise ValidationError(_("This action is only available for Clover transactions.")) if self.state != 'done': raise ValidationError(_("Only confirmed transactions can be voided.")) charge_id = self.clover_charge_id or self.provider_reference if not charge_id: raise ValidationError(_("No Clover charge ID found.")) # Guard against double reversal 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). Voiding would result in a double reversal.", ref=existing_refund.reference, )) provider = self._get_provider_sudo() # Verify on Clover the charge hasn't already been refunded try: charge_data = provider._clover_make_ecom_request( 'GET', f'v1/charges/{charge_id}', ) charge_status = charge_data.get('status', '') if charge_status == 'refunded': raise ValidationError(_( "This charge has already been refunded on Clover. " "It cannot be voided again." )) except ValidationError: raise except Exception: _logger.debug("Could not verify charge %s before void", charge_id) # Issue full refund (acts as void before settlement) try: result = provider._clover_create_refund( charge_id=charge_id, reason=f'Void for {self.reference}', ) except ValidationError as e: error_msg = str(e) if '400' in error_msg or 'declined' in error_msg.lower(): raise ValidationError(_( "Void declined by the payment processor. This usually " "means the batch has already settled. Settled transactions " "cannot be voided.\n\n" "To reverse this payment, create a Credit Note on the " "invoice and process a refund through the Clover refund " "wizard." )) raise _logger.info( "Clover void response: id=%s, status=%s", result.get('id', ''), result.get('status', ''), ) # Cancel the Odoo payment if self.payment_id: self.payment_id.sudo().action_cancel() self.sudo().write({ 'state': 'cancel', 'clover_voided': True, 'clover_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 Clover " "(Clover Refund ID: %(refund_id)s).", ref=self.reference, refund_id=result.get('id', ''), ), ) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'type': 'success', 'message': _("Transaction voided successfully on Clover."), '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 Clover data.""" if provider_code != 'clover': return super()._search_by_reference(provider_code, payment_data) reference = payment_data.get('reference') if reference: tx = self.search([ ('reference', '=', reference), ('provider_code', '=', 'clover'), ]) else: charge_id = payment_data.get('clover_charge_id') if charge_id: tx = self.search([ ('clover_charge_id', '=', charge_id), ('provider_code', '=', 'clover'), ]) else: _logger.warning("Received Clover data with no reference or charge ID") tx = self if not tx: _logger.warning( "No transaction found matching Clover reference %s", reference, ) return tx def _apply_updates(self, payment_data): """Override of `payment` to update the transaction based on Clover data.""" if self.provider_code != 'clover': return super()._apply_updates(payment_data) charge_id = payment_data.get('clover_charge_id') if charge_id: self.provider_reference = charge_id self.clover_charge_id = charge_id refund_id = payment_data.get('clover_refund_id') if refund_id: self.clover_refund_id = refund_id source = payment_data.get('source', {}) if source: card_details = clover_utils.extract_card_details(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('clover_status', '') if not status: self._set_error(_("Received data with missing transaction status.")) return odoo_state = clover_utils.get_clover_status(status) if odoo_state == 'authorized': self._set_authorized() elif odoo_state == 'done': self._set_done() self._post_process() self._clover_generate_receipt(payment_data) elif odoo_state == 'cancel': self._set_canceled() elif odoo_state == 'refund': self._set_done() self._post_process() self._clover_generate_receipt(payment_data) elif odoo_state == 'error': error_msg = payment_data.get('error_message', _("Payment was declined by Clover.")) self._set_error(error_msg) else: _logger.warning( "Received unknown Clover 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 Clover payments directly to the bank account.""" if self.provider_code != 'clover': 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 Clover data.""" if self.provider_code != 'clover': return super()._extract_token_values(payment_data) source = payment_data.get('source', {}) card_details = clover_utils.extract_card_details(source) if not card_details: _logger.warning( "Tokenization requested but no card data in payment response." ) return {} return { 'payment_details': card_details.get('last4', ''), 'clover_source_token': source.get('id', ''), } # === RECEIPT GENERATION === # def _clover_generate_receipt(self, payment_data=None): """Store receipt data and generate a PDF receipt.""" self.ensure_one() if self.provider_code != 'clover' or not self.clover_charge_id: return try: self._clover_store_receipt_data(payment_data) self._clover_attach_receipt_pdf() except Exception: _logger.exception( "Receipt generation failed for transaction %s", self.reference, ) def _clover_store_receipt_data(self, payment_data=None): """Persist receipt-relevant fields as a JSON blob.""" source = payment_data.get('source', {}) if payment_data else {} receipt = { 'charge_id': self.clover_charge_id or '', 'reference': self.reference, 'status': payment_data.get('clover_status', '') if payment_data else '', 'card_brand': source.get('brand', ''), 'card_last4': str(source.get('last4', '')), 'card_first6': str(source.get('first6', '')), 'exp_month': source.get('exp_month', ''), 'exp_year': source.get('exp_year', ''), 'transaction_amount': float(self.amount), 'currency': self.currency_id.name, } self.clover_receipt_data = json.dumps(receipt) def _clover_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_clover.action_report_clover_receipt') pdf_content, _report_type = report._render_qweb_pdf(report.report_name, [self.id]) except Exception: _logger.debug("Could not render Clover 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 _get_clover_receipt_values(self): """Parse the stored receipt JSON for use in QWeb templates.""" self.ensure_one() data = self.clover_receipt_data if not data and self.source_transaction_id: data = self.source_transaction_id.clover_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.""" self.ensure_one() if self.source_transaction_id and self.source_transaction_id.clover_receipt_data: try: return json.loads(self.source_transaction_id.clover_receipt_data) except (json.JSONDecodeError, TypeError): pass return {}