changes
This commit is contained in:
497
fusion_clover/controllers/main.py
Normal file
497
fusion_clover/controllers/main.py
Normal file
@@ -0,0 +1,497 @@
|
||||
# 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.'}
|
||||
Reference in New Issue
Block a user