Files
Odoo-Modules/fusion_clover/controllers/main.py
gsinghpal 92369be6e0 changes
2026-03-20 11:46:41 -04:00

498 lines
18 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import pprint
from odoo import fields, http
from odoo.exceptions import UserError, ValidationError
from odoo.http import request
from odoo.tools import mute_logger
from odoo.addons.fusion_clover import utils as clover_utils
_logger = logging.getLogger(__name__)
def _detect_card_brand(card_number):
"""Detect the card brand from the card number using BIN prefixes."""
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'
class CloverController(http.Controller):
_return_url = '/payment/clover/return'
_webhook_url = '/payment/clover/webhook'
_oauth_callback_url = '/payment/clover/oauth/callback'
# === RETURN ROUTE === #
@http.route(_return_url, type='http', methods=['GET'], auth='public')
def clover_return(self, **data):
"""Process the return from a Clover payment flow."""
tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference(
'clover', data,
)
if tx_sudo and tx_sudo.clover_charge_id:
try:
provider = tx_sudo.provider_id.sudo()
charge_data = provider._clover_make_ecom_request(
'GET', f'v1/charges/{tx_sudo.clover_charge_id}',
)
payment_data = {
'reference': tx_sudo.reference,
'clover_charge_id': charge_data.get('id'),
'clover_status': charge_data.get('status', ''),
'source': charge_data.get('source', {}),
}
tx_sudo._process('clover', payment_data)
except ValidationError:
_logger.error(
"Failed to fetch Clover charge %s on return.",
tx_sudo.clover_charge_id,
)
with mute_logger('werkzeug'):
return request.redirect('/payment/status')
# === WEBHOOK ROUTE === #
@http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
def clover_webhook(self):
"""Process webhook notifications from Clover."""
try:
raw_body = request.httprequest.data.decode('utf-8')
event = json.loads(raw_body)
except (ValueError, UnicodeDecodeError):
_logger.warning("Received invalid JSON from Clover webhook")
return request.make_json_response({'status': 'error'}, status=400)
_logger.info(
"Clover webhook notification received:\n%s",
pprint.pformat(event),
)
try:
event_type = event.get('type', '')
data = event.get('data', {})
if event_type in ('charge.succeeded', 'charge.captured'):
self._handle_charge_webhook(data, 'succeeded')
elif event_type == 'charge.failed':
self._handle_charge_webhook(data, 'failed')
elif event_type == 'charge.voided':
self._handle_void_webhook(data)
elif event_type in ('refund.created', 'refund.succeeded'):
self._handle_refund_webhook(data)
elif event_type == 'refund.failed':
_logger.warning("Clover refund failed webhook: %s", data.get('id', ''))
elif event_type == 'payment.created':
self._handle_charge_webhook(data, 'succeeded')
except ValidationError:
_logger.exception("Unable to process Clover webhook; acknowledging to avoid retries")
return request.make_json_response({'status': 'ok'})
def _handle_charge_webhook(self, data, status):
"""Process a charge-related webhook event."""
charge_id = data.get('id', '')
if not charge_id:
return
payment_data = {
'clover_charge_id': charge_id,
'clover_status': status,
'source': data.get('source', {}),
}
# Try to find by metadata reference first
metadata = data.get('metadata', {})
reference = metadata.get('odoo_reference', '')
if reference:
payment_data['reference'] = reference
tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference(
'clover', payment_data,
)
if tx_sudo:
tx_sudo._process('clover', payment_data)
def _handle_void_webhook(self, data):
"""Process a void webhook event."""
charge_id = data.get('id', '')
if not charge_id:
return
tx_sudo = request.env['payment.transaction'].sudo().search([
('clover_charge_id', '=', charge_id),
('provider_code', '=', 'clover'),
('state', '=', 'done'),
], limit=1)
if not tx_sudo:
return
if not tx_sudo.clover_voided:
tx_sudo.sudo().write({
'state': 'cancel',
'clover_voided': True,
'clover_void_date': fields.Datetime.now(),
})
_logger.info("Clover void webhook: voided transaction %s", tx_sudo.reference)
def _handle_refund_webhook(self, data):
"""Process a refund webhook event."""
refund_id = data.get('id', '')
charge_id = data.get('charge', '')
if not charge_id:
return
source_tx = request.env['payment.transaction'].sudo().search([
('clover_charge_id', '=', charge_id),
('provider_code', '=', 'clover'),
('state', '=', 'done'),
], limit=1)
if not source_tx:
return
existing_refund = source_tx.child_transaction_ids.filtered(
lambda t: t.provider_reference == refund_id
)
if existing_refund:
return
refund_amount_minor = data.get('amount', 0)
refund_amount = clover_utils.parse_clover_amount(
refund_amount_minor, source_tx.currency_id,
)
refund_tx = source_tx._create_child_transaction(
refund_amount, is_refund=True,
)
payment_data = {
'reference': refund_tx.reference,
'clover_charge_id': charge_id,
'clover_refund_id': refund_id,
'clover_status': 'succeeded',
}
refund_tx._process('clover', payment_data)
# === OAUTH CALLBACK ROUTE === #
@http.route(_oauth_callback_url, type='http', methods=['GET'], auth='user')
def clover_oauth_callback(self, **data):
"""Handle the OAuth2 authorization callback from Clover.
After a merchant authorizes the app, Clover redirects here with
an authorization code. We exchange it for an access token and
store the merchant_id.
"""
code = data.get('code', '')
merchant_id = data.get('merchant_id', '')
client_id = data.get('client_id', '')
state = data.get('state', '')
if not code:
_logger.warning("Clover OAuth callback missing authorization code")
return request.redirect('/odoo/settings')
if state:
try:
provider_id = int(state)
provider = request.env['payment.provider'].browse(provider_id)
if provider.exists() and provider.code == 'clover':
# Exchange code for access token
import requests as req
is_test = provider.state == 'test'
token_url = (
f"{data.get('_token_url', '')}"
or (
'https://apisandbox.dev.clover.com/oauth/token' if is_test
else 'https://api.clover.com/oauth/token'
)
)
token_resp = req.get(token_url, params={
'client_id': provider.clover_app_id,
'client_secret': provider.sudo().clover_app_secret,
'code': code,
}, timeout=30)
if token_resp.status_code == 200:
token_data = token_resp.json()
access_token = token_data.get('access_token', '')
if access_token:
vals = {'clover_api_key': access_token}
if merchant_id:
vals['clover_merchant_id'] = merchant_id
provider.sudo().write(vals)
_logger.info(
"Clover OAuth: linked merchant %s to provider %s",
merchant_id, provider_id,
)
else:
_logger.error(
"Clover OAuth token exchange failed: %s",
token_resp.text[:500],
)
except (ValueError, TypeError):
_logger.warning("Invalid provider state in Clover OAuth callback: %s", state)
return request.redirect('/odoo/settings')
# === SURCHARGE HELPER === #
def _apply_portal_surcharge(self, tx_sudo, card_type):
"""Apply credit card surcharge to the linked invoice if enabled."""
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clover.surcharge_enabled', 'False') != 'True':
return 0.0
if not card_type:
card_type = 'other'
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 0.0
invoices = tx_sudo.invoice_ids
if not invoices:
base_amount = tx_sudo.amount
else:
base_amount = sum(invoices.mapped('amount_residual'))
fee_amount = round(base_amount * rate / 100.0, 2)
if fee_amount <= 0:
return 0.0
product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0)
product = request.env['product.product'].sudo().browse(product_id).exists()
if not product:
product = request.env.ref(
'fusion_clover.product_cc_processing_fee', raise_if_not_found=False,
)
if not product:
_logger.warning("Surcharge product not configured; skipping surcharge")
return 0.0
for invoice in invoices.sudo():
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()
new_amount = tx_sudo.amount + fee_amount
tx_sudo.write({'amount': new_amount})
return fee_amount
# === TERMINAL ROUTES === #
@http.route('/payment/clover/terminals', type='jsonrpc', auth='public')
def clover_list_terminals(self, provider_id=None, **kwargs):
"""Return a list of active terminals for the given Clover provider."""
if not provider_id:
return []
terminals = request.env['clover.terminal'].sudo().search([
('provider_id', '=', int(provider_id)),
('active', '=', True),
])
return [
{
'id': t.id,
'name': t.name,
'serial': t.serial_number,
'status': t.status or 'unknown',
'model': t.model_name or '',
}
for t in terminals
]
@http.route('/payment/clover/send_to_terminal', type='jsonrpc', auth='public')
def clover_send_to_terminal(self, reference=None, terminal_id=None,
card_type=None, **kwargs):
"""Send a payment request to a Clover terminal via Cloud Pay Display."""
if not reference or not terminal_id:
return {'error': 'Missing reference or terminal ID.'}
tx_sudo = request.env['payment.transaction'].sudo().search([
('reference', '=', reference),
('provider_code', '=', 'clover'),
], limit=1)
if not tx_sudo:
return {'error': 'Transaction not found.'}
terminal = request.env['clover.terminal'].sudo().browse(int(terminal_id))
if not terminal.exists():
return {'error': 'Terminal not found.'}
try:
if card_type:
self._apply_portal_surcharge(tx_sudo, card_type)
provider = tx_sudo.provider_id.sudo()
capture = not provider.capture_manually
result = terminal.action_send_payment(
amount=tx_sudo.amount,
currency=tx_sudo.currency_id,
reference=reference,
capture=capture,
)
return {'success': True}
except (ValidationError, UserError) as e:
return {'error': str(e)}
@http.route('/payment/clover/terminal_status', type='jsonrpc', auth='public')
def clover_terminal_status(self, reference=None, terminal_id=None, **kwargs):
"""Poll for the status of a terminal payment."""
if not reference or not terminal_id:
return {'status': 'error', 'message': 'Missing reference or terminal ID.'}
terminal = request.env['clover.terminal'].sudo().browse(int(terminal_id))
if not terminal.exists():
return {'status': 'error', 'message': 'Terminal not found.'}
result = terminal.action_check_payment_status(reference)
status = result.get('status', 'pending')
# If payment completed, process the transaction
if status in ('CLOSED', 'AUTH', 'AUTHORIZED', 'CAPTURED'):
tx_sudo = request.env['payment.transaction'].sudo().search([
('reference', '=', reference),
('provider_code', '=', 'clover'),
], limit=1)
if tx_sudo and tx_sudo.state == 'draft':
payment_id = result.get('payment_id', '')
card_txn = result.get('card_transaction', {})
tx_sudo.write({
'clover_charge_id': payment_id or tx_sudo.clover_charge_id,
'provider_reference': payment_id or tx_sudo.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_sudo._process('clover', payment_data)
return result
@http.route('/payment/clover/terminal/callback', type='http',
methods=['POST'], auth='public', csrf=False)
def clover_terminal_callback(self, **data):
"""Handle callback from terminal payment completion (if configured)."""
_logger.info("Clover terminal callback received: %s", data)
return request.make_json_response({'status': 'ok'})
# === JSON-RPC ROUTES (called from frontend JS) === #
@http.route('/payment/clover/process_card', type='jsonrpc', auth='public')
def clover_process_card(self, reference=None, card_token=None,
card_type=None, **kwargs):
"""Process a card payment through Clover Ecommerce API.
The frontend tokenizes the card via Clover's iframe/API and sends
the token here. Card data is NOT stored in Odoo.
:return: Dict with success status or error message.
:rtype: dict
"""
if not reference:
return {'error': 'Missing payment reference.'}
tx_sudo = request.env['payment.transaction'].sudo().search([
('reference', '=', reference),
('provider_code', '=', 'clover'),
], limit=1)
if not tx_sudo:
return {'error': 'Transaction not found.'}
if not card_token:
return {'error': 'Missing card token. Please try again.'}
try:
if card_type:
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type)
provider = tx_sudo.provider_id.sudo()
capture = not provider.capture_manually
result = provider._clover_create_charge(
source_token=card_token,
amount=tx_sudo.amount,
currency=tx_sudo.currency_id,
capture=capture,
description=reference,
ecomind='ecom',
metadata={'odoo_reference': reference},
)
charge_id = result.get('id', '')
status = result.get('status', '')
tx_sudo.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', {}),
}
tx_sudo._process('clover', payment_data)
return {'success': True, 'status': status}
except ValidationError as e:
return {'error': str(e)}
except Exception as e:
_logger.error("Card payment processing failed: %s", e)
return {'error': 'Payment processing failed. Please try again.'}