# Part of Odoo. See LICENSE file for full copyright and licensing details. import logging from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.addons.fusion_clover import utils as clover_utils _logger = logging.getLogger(__name__) class CloverPaymentWizard(models.TransientModel): _name = 'clover.payment.wizard' _description = 'Collect Clover Payment' invoice_id = fields.Many2one( 'account.move', string="Invoice", required=True, readonly=True, domain="[('move_type', 'in', ('out_invoice', 'out_refund'))]", ) partner_id = fields.Many2one( related='invoice_id.partner_id', string="Customer", ) amount = fields.Monetary( string="Amount", required=True, currency_field='currency_id', ) currency_id = fields.Many2one( 'res.currency', string="Currency", required=True, readonly=True, ) provider_id = fields.Many2one( 'payment.provider', string="Clover Provider", required=True, domain="[('code', '=', 'clover'), ('state', '!=', 'disabled')]", ) provider_name = fields.Char( related='provider_id.name', string="Clover Provider", readonly=True, ) # --- Payment mode (terminal vs manual card) --- payment_mode = fields.Selection( selection=[ ('terminal', "Terminal"), ('card', "Manual Card Entry"), ], string="Payment Mode", default='terminal', required=True, ) # --- Terminal fields --- terminal_id = fields.Many2one( 'clover.terminal', string="Terminal", domain="[('provider_id', '=', provider_id), ('active', '=', True)]", ) # --- Card type & surcharge fields --- card_type = fields.Selection( selection=[ ('visa', "Visa"), ('mastercard', "Mastercard"), ('amex', "American Express"), ('debit', "Debit"), ('other', "Other"), ], string="Card Type", ) surcharge_enabled = fields.Boolean( compute='_compute_surcharge_enabled', ) surcharge_rate = fields.Float( string="Surcharge Rate (%)", digits=(5, 2), readonly=True, ) surcharge_amount = fields.Monetary( string="Surcharge Amount", currency_field='currency_id', readonly=True, ) surcharge_applied = fields.Boolean(default=False) original_amount = fields.Monetary( string="Invoice Amount", currency_field='currency_id', readonly=True, ) # --- Card entry fields (never stored, transient only) --- card_number = fields.Char(string="Card Number") exp_month = fields.Char(string="Exp. Month", size=2) exp_year = fields.Char(string="Exp. Year", size=4) cvv = fields.Char(string="CVV", size=4) cardholder_name = fields.Char(string="Cardholder Name") # --- Status tracking --- state = fields.Selection( selection=[ ('draft', "Draft"), ('waiting', "Waiting for Terminal"), ('done', "Payment Collected"), ('error', "Error"), ], default='draft', ) status_message = fields.Text(string="Status", readonly=True) clover_charge_id = fields.Char(readonly=True) clover_payment_id = fields.Char( string="Terminal Payment ID", readonly=True, help="The Clover payment UUID from the terminal response.", ) sent_at = fields.Datetime( string="Sent to Terminal At", readonly=True, ) transaction_id = fields.Many2one( 'payment.transaction', string="Payment Transaction", readonly=True, ) @api.depends_context('uid') def _compute_surcharge_enabled(self): enabled = self.env['ir.config_parameter'].sudo().get_param( 'fusion_clover.surcharge_enabled', 'False', ) == 'True' for rec in self: rec.surcharge_enabled = enabled @staticmethod def _detect_card_brand(card_number): num = (card_number or '').replace(' ', '') if len(num) < 2: return 'other' if num[:2] in ('34', '37'): return 'amex' if num[0] == '4': return 'visa' prefix2 = int(num[:2]) if 51 <= prefix2 <= 55: return 'mastercard' if len(num) >= 4: prefix4 = int(num[:4]) if 2221 <= prefix4 <= 2720: return 'mastercard' return 'other' def _get_surcharge_rate(self, card_type): ICP = self.env['ir.config_parameter'].sudo() 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') return float(ICP.get_param(rate_key, '0') or 0) @api.onchange('card_number') def _onchange_card_number(self): if self.card_number: self.card_type = self._detect_card_brand(self.card_number) @api.onchange('card_type') def _onchange_card_type(self): if not self.card_type or not self.surcharge_enabled: self.surcharge_rate = 0.0 self.surcharge_amount = 0.0 return rate = self._get_surcharge_rate(self.card_type) base_amount = self.original_amount or self.amount self.surcharge_rate = rate self.surcharge_amount = round(base_amount * rate / 100.0, 2) @api.model def default_get(self, fields_list): res = super().default_get(fields_list) invoice_id = self.env.context.get('active_id') active_model = self.env.context.get('active_model') if active_model == 'account.move' and invoice_id: invoice = self.env['account.move'].browse(invoice_id) res['invoice_id'] = invoice.id res['amount'] = invoice.amount_residual res['original_amount'] = invoice.amount_residual res['currency_id'] = invoice.currency_id.id provider = self.env['payment.provider'].sudo().search([ ('code', '=', 'clover'), ('state', '!=', 'disabled'), ], limit=1) if provider: res['provider_id'] = provider.id if provider.clover_default_terminal_id: res['terminal_id'] = provider.clover_default_terminal_id.id return res def _get_provider_sudo(self): return self.provider_id.sudo() def _apply_surcharge_if_needed(self): """Add the surcharge invoice line if surcharge is enabled and not yet applied.""" if self.surcharge_applied or not self.surcharge_enabled: return if not self.card_type: raise UserError(_("Please select the card type to calculate the surcharge.")) rate = self._get_surcharge_rate(self.card_type) if rate <= 0: return base_amount = self.original_amount or self.amount fee_amount = round(base_amount * rate / 100.0, 2) if fee_amount <= 0: return ICP = self.env['ir.config_parameter'].sudo() 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: raise UserError( _("Surcharge product not configured. " "Go to Settings > Fusion Clover to set it up.") ) invoice = self.invoice_id.sudo() was_posted = invoice.state == 'posted' if was_posted: invoice.button_draft() description = _("Credit Card Processing Fee (%(rate).2f%% surcharge)", rate=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() self.write({ 'surcharge_applied': True, 'surcharge_rate': rate, 'surcharge_amount': fee_amount, 'amount': invoice.amount_residual, }) def _remove_surcharge_line(self): """Remove the surcharge line from the invoice if it was applied.""" if not self.surcharge_applied: return ICP = self.env['ir.config_parameter'].sudo() 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: return invoice = self.invoice_id.sudo() surcharge_lines = invoice.invoice_line_ids.filtered( lambda l: l.product_id.id == product.id ) if not surcharge_lines: self.surcharge_applied = False return was_posted = invoice.state == 'posted' if was_posted: invoice.button_draft() surcharge_lines.unlink() if was_posted: invoice.action_post() self.write({ 'surcharge_applied': False, 'surcharge_amount': 0.0, 'surcharge_rate': 0.0, 'amount': invoice.amount_residual, }) def action_collect_payment(self): """Process a payment - either via terminal or manual card entry.""" self.ensure_one() if self.payment_mode == 'terminal': return self._collect_via_terminal() return self._collect_via_card() def _collect_via_terminal(self): """Send payment to Clover terminal via Cloud REST Pay Display API.""" self.ensure_one() self._apply_surcharge_if_needed() if self.amount <= 0: raise UserError(_("Payment amount must be greater than zero.")) if not self.terminal_id: raise UserError(_("Please select a terminal device.")) self._cleanup_draft_transaction() tx = self._create_payment_transaction() reference = tx.reference try: provider = self._get_provider_sudo() capture = not provider.capture_manually result = self.terminal_id.action_send_payment( amount=self.amount, currency=self.currency_id, reference=reference, capture=capture, ) # The terminal response may contain the payment immediately # (if the customer already tapped/swiped), or it may be pending. payment = result.get('payment', {}) payment_id = payment.get('id', '') if payment and payment.get('result') == 'SUCCESS': # Payment completed immediately card_txn = payment.get('cardTransaction', {}) tx.write({ 'clover_charge_id': payment_id, 'provider_reference': payment_id, }) payment_data = { 'reference': reference, 'clover_charge_id': payment_id, 'clover_status': 'succeeded', 'source': { 'brand': card_txn.get('cardType', ''), 'last4': card_txn.get('last4', ''), }, } tx._process('clover', payment_data) self.write({ 'state': 'done', 'status_message': _( "Payment collected successfully. Payment ID: %(pid)s", pid=payment_id, ), 'clover_payment_id': payment_id, }) return self._reopen_wizard() # Payment sent to terminal, waiting for customer interaction self.write({ 'state': 'waiting', 'status_message': _("Payment sent to terminal. Waiting for customer..."), 'sent_at': fields.Datetime.now(), 'clover_payment_id': payment_id or '', }) return self._reopen_wizard() except (ValidationError, UserError) as e: self._cleanup_draft_transaction() self._remove_surcharge_line() self.write({ 'state': 'error', 'status_message': str(e), }) return self._reopen_wizard() def action_check_status(self): """Poll the terminal for payment completion status.""" self.ensure_one() if not self.terminal_id or not self.transaction_id: raise UserError(_("No terminal or transaction to check.")) tx = self.transaction_id reference = tx.reference result = self.terminal_id.action_check_payment_status(reference) status = result.get('status', 'pending') if status in ('CLOSED', 'AUTH', 'AUTHORIZED', 'CAPTURED'): payment_id = result.get('payment_id', '') card_txn = result.get('card_transaction', {}) tx.write({ 'clover_charge_id': payment_id or tx.clover_charge_id, 'provider_reference': payment_id or tx.provider_reference, }) payment_data = { 'reference': reference, 'clover_charge_id': payment_id, 'clover_status': 'succeeded', 'source': { 'brand': card_txn.get('cardType', ''), 'last4': card_txn.get('last4', ''), }, } tx._process('clover', payment_data) self.write({ 'state': 'done', 'status_message': _( "Payment collected successfully. Payment ID: %(pid)s", pid=payment_id, ), 'clover_payment_id': payment_id, }) elif status in ('DECLINED', 'FAIL', 'FAILED'): tx._set_error( _("Payment was declined by the terminal.") ) self._cleanup_draft_transaction() self._remove_surcharge_line() self.write({ 'state': 'error', 'status_message': result.get('message', _("Payment declined at terminal.")), }) elif status == 'error': self.write({ 'status_message': result.get('message', _("Error checking status.")), }) else: self.write({ 'status_message': result.get('message', _("Still waiting for terminal...")), }) return self._reopen_wizard() def _collect_via_card(self): """Process a manual card entry payment via Clover Ecommerce API.""" self.ensure_one() self._apply_surcharge_if_needed() if self.amount <= 0: raise UserError(_("Payment amount must be greater than zero.")) self._validate_card_fields() self._cleanup_draft_transaction() tx = self._create_payment_transaction() reference = tx.reference try: provider = self._get_provider_sudo() capture = not provider.capture_manually # Tokenize the card client-server FIRST (Clover's tokenization # endpoint), then charge using only the token. Raw PAN never # reaches /v1/charges and is never persisted in Odoo. card_token = provider._clover_tokenize_card( card_number=self.card_number, exp_month=int(self.exp_month), exp_year=int(self.exp_year) if len(self.exp_year) == 4 else 2000 + int(self.exp_year), cvv=self.cvv, cardholder_name=self.cardholder_name or '', ) result = provider._clover_create_charge( source_token=card_token, amount=self.amount, currency=self.currency_id, capture=capture, description=reference, ecomind='moto', metadata={'odoo_reference': reference}, ) charge_id = result.get('id', '') status = result.get('status', '') tx.write({ 'clover_charge_id': charge_id, 'provider_reference': charge_id, }) payment_data = { 'reference': reference, 'clover_charge_id': charge_id, 'clover_status': status, 'source': result.get('source', {}), # Pass amount + currency back so Odoo 19's amount-tamper # check (_validate_amount) can verify Clover charged the # exact amount we asked for. 'amount': result.get('amount'), 'currency': result.get('currency'), } if status == 'failed': tx._set_error( _("Payment was %(status)s by the processor.", status=status) ) self._cleanup_draft_transaction() self._remove_surcharge_line() outcome = result.get('outcome', {}) decline_msg = outcome.get('type', status) self.write({ 'state': 'error', 'status_message': _( "Payment %(status)s: %(reason)s", status=status, reason=decline_msg, ), 'clover_charge_id': charge_id, }) return self._reopen_wizard() tx._process('clover', payment_data) self.write({ 'state': 'done', 'status_message': _( "Payment collected successfully. Charge: %(charge_id)s", charge_id=charge_id, ), 'clover_charge_id': charge_id, }) return self._reopen_wizard() except (ValidationError, UserError) as e: self._cleanup_draft_transaction() self._remove_surcharge_line() self.write({ 'state': 'error', 'status_message': str(e), }) return self._reopen_wizard() def action_send_receipt(self): """Email the payment receipt to the customer and close the wizard.""" self.ensure_one() tx = self.transaction_id if not tx: raise UserError(_("No payment transaction found.")) template = self.env.ref( 'fusion_clover.mail_template_clover_receipt', raise_if_not_found=False, ) if not template: raise UserError(_("Receipt email template not found.")) template.send_mail(tx.id, force_send=True) return {'type': 'ir.actions.act_window_close'} def action_cancel_payment(self): """Cancel the payment and clean up the draft transaction.""" self.ensure_one() self._cleanup_draft_transaction() self._remove_surcharge_line() return {'type': 'ir.actions.act_window_close'} def _cleanup_draft_transaction(self): """Remove the draft payment transaction created by this wizard.""" if not self.transaction_id: return tx = self.transaction_id.sudo() if tx.state == 'draft': tx.invoice_ids = [(5,)] tx.unlink() self.transaction_id = False # === HELPERS === # def _validate_card_fields(self): """Validate that card entry fields are properly filled.""" if not self.card_number or len(self.card_number.replace(' ', '')) < 13: raise UserError(_("Please enter a valid card number.")) if not self.exp_month or not self.exp_month.isdigit(): raise UserError(_("Please enter a valid expiry month (01-12).")) if not self.exp_year or not self.exp_year.isdigit() or len(self.exp_year) < 2: raise UserError(_("Please enter a valid expiry year.")) if not self.cvv or not self.cvv.isdigit(): raise UserError(_("Please enter the CVV.")) # Clover production rejects cards without a 2-part name # ("Firstname Lastname"). Sandbox is lenient. Validate here so the # error message is helpful instead of a generic API 400. if not self.cardholder_name or len(self.cardholder_name.strip().split()) < 2: raise UserError(_( "Please enter the cardholder's name as it appears on " "the card (e.g. \"John Doe\"). Clover requires both " "first and last name." )) def _create_payment_transaction(self): """Create a payment.transaction linked to the invoice.""" PaymentMethod = self.env['payment.method'].sudo().with_context(active_test=False) payment_method = PaymentMethod.search( [('code', '=', 'card')], limit=1, ) if not payment_method: payment_method = PaymentMethod.search( [('code', 'in', ('visa', 'mastercard'))], limit=1, ) if not payment_method: raise UserError( _("No card payment method found. Please configure one " "in Settings > Payment Methods.") ) tx_values = { 'provider_id': self.provider_id.id, 'payment_method_id': payment_method.id, 'amount': self.amount, 'currency_id': self.currency_id.id, 'partner_id': self.partner_id.id, 'operation': 'offline', 'invoice_ids': [(4, self.invoice_id.id)], } tx = self.env['payment.transaction'].sudo().create(tx_values) self.transaction_id = tx return tx def _reopen_wizard(self): """Return an action that re-opens this wizard record (keeps state).""" return { 'type': 'ir.actions.act_window', 'name': _("Collect Clover Payment"), 'res_model': self._name, 'res_id': self.id, 'views': [(False, 'form')], 'target': 'new', }