This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

View File

@@ -56,12 +56,43 @@ class PaymentProvider(models.Model):
copy=False,
groups='base.group_system',
)
clover_remote_app_id = fields.Char(
string="Remote App ID (RAID)",
help="The Remote App ID generated when the Clover dev app's "
"App Type is set to Web > 'Is this an integration of an "
"existing point of sale?' = Yes. Sent in the X-POS-Id "
"header on every REST Pay Display call. Required for "
"production terminal payments.",
copy=False,
)
clover_public_key = fields.Char(
string="Public API Key (PAKMS)",
help="The public token from Clover's Ecommerce API Tokens page. "
"Used for client-side tokenization. Safe to expose in the browser.",
copy=False,
)
clover_oauth_access_token = fields.Char(
string="OAuth Access Token",
help="Long-lived merchant OAuth access_token, obtained via the "
"Connect to Clover button. Used as the Bearer token for "
"all Platform API and REST Pay Display calls. Different "
"from the Ecommerce private token (clover_api_key).",
copy=False,
groups='base.group_system',
)
clover_oauth_refresh_token = fields.Char(
string="OAuth Refresh Token",
help="Refresh token used to renew the access token before it "
"expires.",
copy=False,
groups='base.group_system',
)
clover_oauth_token_expiry = fields.Datetime(
string="OAuth Token Expiry",
help="When the current access_token expires. The refresh flow "
"should run automatically before this time.",
copy=False,
)
clover_default_terminal_id = fields.Many2one(
'clover.terminal',
string="Default Terminal",
@@ -90,9 +121,292 @@ class PaymentProvider(models.Model):
return super()._get_default_payment_method_codes()
return const.DEFAULT_PAYMENT_METHOD_CODES
# === BUSINESS METHODS - OAUTH HELPERS === #
def _clover_get_platform_token(self):
"""Pick the best Bearer token for Platform / REST Pay Display calls.
Priority order:
1. Live OAuth access_token (preferred — refreshable, app-scoped,
grants both Platform API and REST Pay Display).
2. Manual REST API token from Clover Dashboard > Setup > API Tokens
(legacy / single-merchant fallback).
3. Ecommerce private token (last-resort — works only for the
Platform GET /v3/merchants/{mId} test-connection call;
will 401 on REST Pay Display).
Will proactively refresh the OAuth access_token if it's expired
(or within a 60-second grace window of expiry).
"""
self.ensure_one()
if self.clover_oauth_access_token:
self._clover_refresh_oauth_if_needed()
return self.clover_oauth_access_token
if self.clover_rest_api_token:
return self.clover_rest_api_token
return self.clover_api_key or ''
def _clover_refresh_oauth_if_needed(self, grace_seconds=60):
"""Refresh the OAuth access_token if it's within ``grace_seconds``
of expiry. Silent no-op if no expiry / no refresh_token / refresh
endpoint not available. Logs at WARNING on refresh failure but
does not raise (the next API call will get 401 and may trigger
retry-with-refresh from the request method)."""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_oauth_refresh_token:
return False
if self.clover_oauth_token_expiry:
time_left = self.clover_oauth_token_expiry - datetime.utcnow()
if time_left > timedelta(seconds=grace_seconds):
return False # plenty of time
return self._clover_refresh_oauth_token()
def _clover_refresh_oauth_token(self):
"""Use the stored refresh_token to mint a new access_token via
Clover's /oauth/v2/refresh. Stores the new token + new expiry on
the provider record. Returns True on success, False on failure
(caller decides whether to fall back or raise)."""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_oauth_refresh_token or not self.clover_app_id:
return False
is_test = self.state == 'test'
url = const.OAUTH_REFRESH_URL_TEST if is_test else const.OAUTH_REFRESH_URL
try:
r = requests.post(
url,
json={
'client_id': self.clover_app_id,
'refresh_token': self.clover_oauth_refresh_token,
},
timeout=20,
)
except requests.exceptions.RequestException as e:
_logger.warning("Clover OAuth refresh network error: %s", e)
return False
if r.status_code != 200:
_logger.warning(
"Clover OAuth refresh failed (%s): %s",
r.status_code, r.text[:300],
)
return False
try:
data = r.json()
except ValueError:
_logger.warning("Clover OAuth refresh response is not JSON")
return False
new_token = data.get('access_token', '')
if not new_token:
return False
vals = {'clover_oauth_access_token': new_token}
new_refresh = data.get('refresh_token', '')
if new_refresh:
vals['clover_oauth_refresh_token'] = new_refresh
expires = int(data.get('access_token_expiration', 0) or 0)
if expires:
if expires > 10 * 365 * 24 * 3600: # absolute timestamp
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires)
else: # duration in seconds
vals['clover_oauth_token_expiry'] = (
datetime.utcnow() + timedelta(seconds=expires)
)
self.sudo().write(vals)
_logger.info("Clover OAuth access_token refreshed for provider %s", self.id)
return True
def _clover_dispatcher_url(self):
"""Return the Nexa OAuth dispatcher URL (where Clover redirects
after the merchant authorises). Configurable per-deployment via
ir.config_parameter ``fusion_clover.dispatcher_url``."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_clover.dispatcher_url',
'https://oauth.nexasystems.ca/clover/callback',
)
def _clover_dispatcher_secret(self):
"""Return the HMAC secret shared with the Nexa OAuth dispatcher.
Stored in ``ir.config_parameter`` ``fusion_clover.dispatcher_secret``
so it can be rotated without a code deploy. Returns '' if not
configured (in which case the OAuth flow falls back to a direct
callback to this Odoo instance — useful for local dev)."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_clover.dispatcher_secret', '',
)
def _clover_build_signed_state(self, customer_slug=''):
"""Build the HMAC-SHA256 signed state token sent to Clover's
OAuth authorize endpoint. The Nexa dispatcher Worker verifies
this signature and uses the embedded ``redirect_to`` to fan the
OAuth callback back to THIS Odoo instance.
Format: ``<base64url(payload_json)>.<base64url(hmac_sha256(secret, payload))>``
"""
import base64
import hashlib
import hmac as _hmac
import json as _json
import secrets
import time
self.ensure_one()
secret = self._clover_dispatcher_secret()
if not secret:
raise UserError(_(
"The dispatcher HMAC secret is not configured. Set "
"the system parameter "
"'fusion_clover.dispatcher_secret' to the value "
"stored in the Cloudflare Worker before initiating "
"the Connect to Clover flow."
))
base_url = self.get_base_url()
payload = {
'redirect_to': f"{base_url}/payment/clover/oauth/callback",
'nonce': secrets.token_hex(16),
'iat': int(time.time()),
'customer': customer_slug or self.company_id.name or '',
}
payload_json = _json.dumps(payload, separators=(',', ':')).encode('utf-8')
payload_b64u = base64.urlsafe_b64encode(payload_json).rstrip(b'=').decode('ascii')
sig = _hmac.new(
secret.encode('utf-8'),
payload_b64u.encode('ascii'),
hashlib.sha256,
).digest()
sig_b64u = base64.urlsafe_b64encode(sig).rstrip(b'=').decode('ascii')
return f"{payload_b64u}.{sig_b64u}"
def action_clover_oauth_connect(self):
"""Kick off the OAuth flow: redirect the staff user to Clover's
authorize endpoint, with our dispatcher URL as redirect_uri and
a signed state encoding this Odoo's callback URL."""
from werkzeug.urls import url_encode
self.ensure_one()
if self.code != 'clover':
raise UserError(_("This action is only available for Clover providers."))
if not self.clover_app_id:
raise UserError(_(
"App ID is required. Add it on the payment provider record "
"under 'OAuth (Optional)' first."
))
is_test = self.state == 'test'
authorize_url = (
const.OAUTH_AUTHORIZE_URL_TEST if is_test else const.OAUTH_AUTHORIZE_URL
)
state = self._clover_build_signed_state()
# Per https://docs.clover.com/dev/docs/high-trust-app-auth-flow the
# v2 authorize endpoint only takes client_id + redirect_uri.
# `state` is documented elsewhere as supported for CSRF; we include
# it. Notably ABSENT: `response_type` (legacy v1 only) and `scope`
# (handled by app's Requested Permissions in the dev dashboard).
params = {
'client_id': self.clover_app_id,
'redirect_uri': self._clover_dispatcher_url(),
'state': state,
}
return {
'type': 'ir.actions.act_url',
'url': f"{authorize_url}?{url_encode(params)}",
'target': 'self',
}
def _clover_exchange_oauth_code(self, code):
"""Exchange a one-time OAuth ``code`` for a long-lived
access_token + refresh_token by POSTing to Clover's token
endpoint with our App Secret. Stores the tokens on this
provider record.
Tries the v2 token endpoint first; if Clover responds with 401
"Failed to validate authentication code", it means the code was
generated by the legacy partial OAuth flow (App Market Connect
without an Alternate Launch Path) — in that case we fall back to
the v1 token endpoint which is what generated the code.
"""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_app_id or not self.clover_app_secret:
raise ValidationError(_(
"Cannot exchange OAuth code: App ID or App Secret is missing."
))
is_test = self.state == 'test'
v2_url = const.OAUTH_TOKEN_URL_TEST if is_test else const.OAUTH_TOKEN_URL
# Legacy v1 token URL — same host, no /v2/.
v1_url = v2_url.replace('/oauth/v2/token', '/oauth/token')
payload = {
'client_id': self.clover_app_id,
'client_secret': self.clover_app_secret,
'code': code,
}
data = None
last_error = ''
for token_url in (v2_url, v1_url):
try:
# v1 endpoint historically used GET with query params;
# v2 uses POST with JSON body. We send POST+JSON to v1
# too (Clover accepts both there in our experience).
response = requests.post(token_url, json=payload, timeout=30)
except requests.exceptions.RequestException as e:
raise ValidationError(_("Could not reach Clover OAuth token endpoint: %s", e))
if response.status_code < 400:
try:
data = response.json()
except ValueError:
raise ValidationError(_("Clover OAuth response was not valid JSON."))
_logger.info("Clover OAuth code exchanged successfully via %s", token_url)
break
last_error = response.text[:500]
_logger.warning(
"Clover OAuth exchange via %s returned %s: %s — trying next endpoint",
token_url, response.status_code, last_error,
)
# Only try v1 fallback if v2 specifically said "Failed to
# validate authentication code" — other 4xx errors won't
# benefit from the fallback.
if 'validate authentication code' not in last_error.lower():
break
if data is None:
raise ValidationError(_(
"Clover rejected the OAuth code at both v2 and v1 token endpoints. "
"Last response: %s", last_error,
))
access_token = data.get('access_token', '')
refresh_token = data.get('refresh_token', '')
expires_in = int(data.get('access_token_expiration', 0) or 0)
if not access_token:
raise ValidationError(_("Clover OAuth response did not contain an access_token."))
vals = {
'clover_oauth_access_token': access_token,
'clover_oauth_refresh_token': refresh_token or False,
}
if expires_in:
# Clover returns the expiry as a UNIX timestamp in seconds, not
# a duration. Detect both shapes (a duration is < ~10 years).
if expires_in > 10 * 365 * 24 * 3600:
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires_in)
else:
vals['clover_oauth_token_expiry'] = (
datetime.utcnow() + timedelta(seconds=expires_in)
)
self.sudo().write(vals)
return True
# === BUSINESS METHODS - API REQUESTS === #
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None):
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None, _retry=True):
"""Make an authenticated API request to the Clover Ecommerce API.
:param str method: HTTP method (GET, POST, PUT, DELETE).
@@ -109,8 +423,16 @@ class PaymentProvider(models.Model):
url = clover_utils.build_ecom_url(endpoint, is_test=is_test)
idempotency_key = clover_utils.generate_idempotency_key()
# Auth precedence: OAuth access_token (works for both Ecommerce
# AND Platform APIs per Clover docs) > Ecommerce private token.
# Routed through _clover_get_platform_token() so the OAuth token
# is proactively refreshed if it's near expiry.
if self.clover_oauth_access_token:
ecom_token = self._clover_get_platform_token()
else:
ecom_token = self.clover_api_key or ''
headers = clover_utils.build_ecom_headers(
self.clover_api_key, idempotency_key=idempotency_key,
ecom_token, idempotency_key=idempotency_key,
)
_logger.info(
@@ -142,6 +464,13 @@ class PaymentProvider(models.Model):
_logger.error("Clover returned non-JSON response: %s", response.text[:500])
raise ValidationError(_("Clover returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Ecom 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_make_ecom_request(
method, endpoint, payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error = result.get('error', {})
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
@@ -161,7 +490,7 @@ class PaymentProvider(models.Model):
return result
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None):
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None, _retry=True):
"""Make an authenticated request to the Clover Platform API.
:param str method: HTTP method.
@@ -179,8 +508,8 @@ class PaymentProvider(models.Model):
endpoint, merchant_id=self.clover_merchant_id, is_test=is_test,
)
# Platform API uses the REST API token, falling back to ecom key
api_token = self.clover_rest_api_token or self.clover_api_key
# Platform API auth precedence: OAuth > merchant REST API token > ecom key
api_token = self._clover_get_platform_token()
headers = clover_utils.build_ecom_headers(api_token)
_logger.info("Clover Platform API %s request to %s", method, url)
@@ -208,6 +537,13 @@ class PaymentProvider(models.Model):
return {}
raise ValidationError(_("Clover returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Platform 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_make_platform_request(
method, endpoint, payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error_msg = result.get('message', result.get('error', 'Unknown error'))
raise ValidationError(
@@ -217,6 +553,122 @@ class PaymentProvider(models.Model):
return result
# === BUSINESS METHODS - TOKENIZATION === #
@staticmethod
def _clover_detect_brand_from_pan(pan):
"""Detect the card brand from a digits-only PAN, returning one of
Clover's accepted brand codes (VISA, MC, AMEX, DISCOVER, DINERS,
JCB). Falls back to VISA if unrecognised — Clover will then
decline at the charge step rather than the tokenize step, which
is a slightly more useful error path."""
if not pan or len(pan) < 2:
return 'VISA'
if pan[:2] in ('34', '37'):
return 'AMEX'
if pan[0] == '4':
return 'VISA'
try:
p2 = int(pan[:2])
p4 = int(pan[:4]) if len(pan) >= 4 else 0
except ValueError:
return 'VISA'
if 51 <= p2 <= 55:
return 'MC'
if 2221 <= p4 <= 2720:
return 'MC'
if pan[:4] == '6011' or pan[:2] == '65':
return 'DISCOVER'
if pan[:4] in ('3014', '3036', '3038') or pan[:2] in ('30', '36', '38'):
return 'DINERS'
if pan[:2] == '35':
return 'JCB'
return 'VISA'
def _clover_tokenize_card(self, card_number, exp_month, exp_year, cvv,
cardholder_name='', postal_code=''):
"""Server-side card tokenization via Clover's tokenization service.
Used by the back-office wizard for staff-keyed card entry. Returns
a ``clv_xxx`` token that can then be passed to ``/v1/charges`` as
the ``source``. The raw PAN is sent ONCE to Clover's tokenization
endpoint and never persisted in Odoo.
Note: Westin Healthcare staff should normally use the terminal
flow rather than manual card entry, which keeps even this brief
in-memory PAN handling out of the picture.
:param str card_number: Card number, digits only.
:param int exp_month: Expiry month (1-12).
:param int exp_year: Expiry year (4-digit).
:param str cvv: Card verification value.
:param str cardholder_name: Optional name on card.
:param str postal_code: Optional billing postal code.
:return: The Clover token string ``clv_xxx``.
:rtype: str
:raises ValidationError: If tokenization fails.
"""
self.ensure_one()
is_test = self.state == 'test'
base_url = const.TOKEN_BASE_URL_TEST if is_test else const.TOKEN_BASE_URL
url = f"{base_url}/v1/tokens"
if not self.clover_public_key:
raise ValidationError(_(
"No Clover Public API Key (PAKMS) configured. Add it on "
"the payment provider record before staff can charge cards "
"from the back-office wizard."
))
# Clover's tokenization endpoint requires the lowercase `apikey`
# header (NOT the `apiAccessKey` field name returned by /pakms).
# https://docs.clover.com/dev/reference/create-card-token
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'apikey': self.clover_public_key,
}
# Detect card brand from the BIN. Clover requires a real brand
# name (VISA/MC/AMEX/DISCOVER/DINERS/JCB) — `CARD` is not valid.
clean_pan = str(card_number).replace(' ', '').replace('-', '')
brand = self._clover_detect_brand_from_pan(clean_pan)
payload = {
'card': {
'number': clean_pan,
'exp_month': str(exp_month).zfill(2),
'exp_year': str(exp_year),
'cvv': str(cvv),
'brand': brand,
},
}
if cardholder_name:
payload['card']['name'] = cardholder_name
if postal_code:
payload['card']['address_zip'] = postal_code
try:
response = requests.post(url, json=payload, headers=headers, timeout=30)
except requests.exceptions.RequestException as e:
raise ValidationError(_("Could not reach Clover tokenization service: %s", e))
try:
result = response.json()
except ValueError:
raise ValidationError(_("Clover returned an invalid tokenization response."))
if response.status_code >= 400 or 'id' not in result:
error = result.get('error', {})
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
raise ValidationError(
_("Clover tokenization failed (%(code)s): %(msg)s",
code=response.status_code, msg=error_msg or 'Unknown error')
)
token = result['id']
if not token.startswith('clv_'):
raise ValidationError(_("Unexpected token format from Clover: %s", token))
return token
# === BUSINESS METHODS - CHARGE / TOKENIZE === #
def _clover_create_charge(self, source_token, amount, currency,
@@ -360,9 +812,19 @@ class PaymentProvider(models.Model):
"""
self.ensure_one()
partner = self.env['res.partner'].browse(partner_id).exists()
partner = self.env['res.partner'].browse(int(partner_id)).exists() if partner_id else self.env['res.partner']
minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0
# Map the Odoo language to a Clover-supported locale (Clover only
# supports en-US, en-CA, fr-CA today). Anything else falls back to
# en-US (Clover SDK default).
partner_lang = (partner.lang or self.env.user.lang or 'en_US').replace('_', '-')
clover_locale = ''
if partner_lang.startswith('fr'):
clover_locale = 'fr-CA'
elif partner_lang in ('en-CA',):
clover_locale = 'en-CA'
inline_form_values = {
'provider_id': self.id,
'merchant_id': self.clover_merchant_id,
@@ -371,6 +833,7 @@ class PaymentProvider(models.Model):
'minor_amount': minor_amount,
'capture_method': 'manual' if self.capture_manually else 'automatic',
'is_test': self.state == 'test',
'locale': clover_locale,
'billing_details': {
'name': partner.name or '',
'email': partner.email or '',
@@ -411,7 +874,7 @@ class PaymentProvider(models.Model):
# === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === #
def _clover_terminal_request(self, method, endpoint, serial_number=None,
payload=None, params=None):
payload=None, params=None, _retry=True):
"""Make a request to the Clover REST Pay Display Cloud API.
Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display).
@@ -434,8 +897,12 @@ class PaymentProvider(models.Model):
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': f'Bearer {self.clover_rest_api_token or self.clover_api_key}',
'X-POS-ID': 'FusionCloverOdoo',
'Authorization': f'Bearer {self._clover_get_platform_token()}',
# X-POS-Id MUST be the Remote App ID (RAID) from the Clover
# developer dashboard (App Type > Web > "is integration of an
# existing POS" = Yes). Falls back to a static identifier in
# sandbox where some merchants use the legacy free-form value.
'X-POS-Id': self.clover_remote_app_id or 'FusionCloverOdoo',
}
if serial_number:
headers['X-Clover-Device-Id'] = serial_number
@@ -472,6 +939,14 @@ class PaymentProvider(models.Model):
_logger.error("Clover Terminal returned non-JSON: %s", response.text[:500])
raise ValidationError(_("Clover terminal returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Terminal 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_terminal_request(
method, endpoint, serial_number=serial_number,
payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error_msg = result.get('message', result.get('error', 'Unknown error'))
_logger.error(

View File

@@ -442,6 +442,51 @@ class PaymentTransaction(models.Model):
return tx
def _extract_amount_data(self, payment_data):
"""Override of `payment` for the Odoo 19 amount-tamper check.
Odoo's ``_validate_amount`` calls this and KErrors if the dict
doesn't contain ``amount`` + ``currency_code``. Other providers
always include these in their ``payment_data``; we historically
didn't. Extract from the Clover charge response shape:
- source token charges (Ecommerce):
``{'amount': <cents>, 'currency': 'usd', ...}`` (raw Clover
response usually shoved into ``payment_data['raw']``)
- terminal payments: ``payment_data['amount']`` (we set this
explicitly when we build the dict)
Returns ``None`` to opt out of the amount check when no usable
amount field is present — the alternative (hard-failing the
transaction) is worse than skipping validation, and our charge
amounts are server-controlled (we send them; Clover doesn't
invent new amounts).
"""
if self.provider_code != 'clover':
return super()._extract_amount_data(payment_data)
amount_minor = payment_data.get('amount')
currency_code = payment_data.get('currency')
# Some code paths put the raw Clover response under 'raw' or pass
# the whole charge dict — try those fallbacks before opting out.
if amount_minor is None and isinstance(payment_data.get('raw'), dict):
amount_minor = payment_data['raw'].get('amount')
currency_code = payment_data['raw'].get('currency') or currency_code
if amount_minor is None or not currency_code:
return None # opt out of amount validation (server-controlled)
# Clover amounts are in minor units (cents for USD/CAD, no
# decimals for JPY/KRW). Convert back to major units using the
# transaction's currency to know the divisor.
from odoo.addons.fusion_clover import utils as clover_utils
amount_major = clover_utils.parse_clover_amount(
int(amount_minor), self.currency_id,
)
return {
'amount': float(amount_major),
'currency_code': str(currency_code).upper(),
}
def _apply_updates(self, payment_data):
"""Override of `payment` to update the transaction based on Clover data."""
if self.provider_code != 'clover':