498 lines
18 KiB
Python
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.'}
|