Files
Odoo-Modules/fusion_poynt/models/payment_provider.py
gsinghpal 0e1aebe60b feat: add Pending status for delivery/technician tasks
- New 'pending' status allows tasks to be created without a schedule,
  acting as a queue for unscheduled work that gets assigned later
- Pending group appears in the Delivery Map sidebar with amber color
- Other modules can create tasks in pending state for scheduling
- scheduled_date no longer required (null for pending tasks)
- New Pending Tasks menu item under Field Service
- Pending filter added to search view

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 04:21:05 -05:00

378 lines
13 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import time
import requests
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.addons.fusion_poynt import const
from odoo.addons.fusion_poynt import utils as poynt_utils
_logger = logging.getLogger(__name__)
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
code = fields.Selection(
selection_add=[('poynt', "Poynt")],
ondelete={'poynt': 'set default'},
)
poynt_application_id = fields.Char(
string="Application ID",
help="The Poynt application ID (urn:aid:...) from your developer portal.",
required_if_provider='poynt',
copy=False,
)
poynt_private_key = fields.Text(
string="Private Key (PEM)",
help="The RSA private key in PEM format, downloaded from the Poynt developer portal. "
"Used to sign JWT tokens for OAuth2 authentication.",
required_if_provider='poynt',
copy=False,
groups='base.group_system',
)
poynt_business_id = fields.Char(
string="Business ID",
help="The merchant's Poynt business UUID.",
required_if_provider='poynt',
copy=False,
)
poynt_store_id = fields.Char(
string="Store ID",
help="The Poynt store UUID for this location.",
copy=False,
)
poynt_webhook_secret = fields.Char(
string="Webhook Secret",
help="Secret key used to verify webhook notifications from Poynt.",
copy=False,
groups='base.group_system',
)
# Cached access token fields (not visible in UI)
_poynt_access_token = fields.Char(
string="Access Token",
copy=False,
groups='base.group_system',
)
_poynt_token_expiry = fields.Integer(
string="Token Expiry Timestamp",
copy=False,
groups='base.group_system',
)
# === COMPUTE METHODS === #
def _compute_feature_support_fields(self):
"""Override of `payment` to enable additional features."""
super()._compute_feature_support_fields()
self.filtered(lambda p: p.code == 'poynt').update({
'support_manual_capture': 'full_only',
'support_refund': 'partial',
'support_tokenization': True,
})
# === CRUD METHODS === #
def _get_default_payment_method_codes(self):
"""Override of `payment` to return the default payment method codes."""
self.ensure_one()
if self.code != 'poynt':
return super()._get_default_payment_method_codes()
return const.DEFAULT_PAYMENT_METHOD_CODES
# === BUSINESS METHODS - AUTHENTICATION === #
def _poynt_get_access_token(self):
"""Obtain an OAuth2 access token from Poynt using JWT bearer grant.
Caches the token and only refreshes when it's about to expire.
:return: The access token string.
:rtype: str
:raises ValidationError: If authentication fails.
"""
self.ensure_one()
now = int(time.time())
if self._poynt_access_token and self._poynt_token_expiry and now < self._poynt_token_expiry - 30:
return self._poynt_access_token
jwt_assertion = poynt_utils.create_self_signed_jwt(
self.poynt_application_id,
self.poynt_private_key,
)
token_url = f"{const.API_BASE_URL}{const.TOKEN_ENDPOINT}"
if self.state == 'test':
token_url = f"{const.API_BASE_URL_TEST}{const.TOKEN_ENDPOINT}"
try:
response = requests.post(
token_url,
data={
'grantType': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion': jwt_assertion,
},
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Api-Version': const.API_VERSION,
},
timeout=30,
)
response.raise_for_status()
token_data = response.json()
except requests.exceptions.RequestException as e:
_logger.error("Poynt OAuth2 token request failed: %s", e)
raise ValidationError(
_("Failed to authenticate with Poynt. Please check your credentials. Error: %s", e)
)
access_token = token_data.get('accessToken')
expires_in = token_data.get('expiresIn', 900)
if not access_token:
raise ValidationError(
_("Poynt authentication returned no access token. "
"Please verify your Application ID and Private Key.")
)
self.sudo().write({
'_poynt_access_token': access_token,
'_poynt_token_expiry': now + expires_in,
})
return access_token
# === BUSINESS METHODS - API REQUESTS === #
def _poynt_make_request(self, method, endpoint, payload=None, params=None,
business_scoped=True, store_scoped=False):
"""Make an authenticated API request to the Poynt REST API.
:param str method: HTTP method (GET, POST, PUT, PATCH, DELETE).
:param str endpoint: The API endpoint path.
:param dict payload: The JSON request body (optional).
:param dict params: The query parameters (optional).
:param bool business_scoped: Whether to scope the URL to the business.
:param bool store_scoped: Whether to scope the URL to the store.
:return: The parsed JSON response.
:rtype: dict
:raises ValidationError: If the API request fails.
"""
self.ensure_one()
access_token = self._poynt_get_access_token()
is_test = self.state == 'test'
business_id = self.poynt_business_id if business_scoped else None
store_id = self.poynt_store_id if store_scoped and self.poynt_store_id else None
url = poynt_utils.build_api_url(
endpoint,
business_id=business_id,
store_id=store_id,
is_test=is_test,
)
request_id = poynt_utils.generate_request_id()
headers = poynt_utils.build_api_headers(access_token, request_id=request_id)
_logger.info(
"Poynt API %s request to %s (request_id=%s)",
method, url, request_id,
)
try:
response = requests.request(
method,
url,
json=payload,
params=params,
headers=headers,
timeout=60,
)
except requests.exceptions.RequestException as e:
_logger.error("Poynt API request failed: %s", e)
raise ValidationError(_("Communication with Poynt failed: %s", e))
if response.status_code == 401:
self.sudo().write({
'_poynt_access_token': False,
'_poynt_token_expiry': 0,
})
raise ValidationError(
_("Poynt authentication expired. Please retry.")
)
if response.status_code == 204:
return {}
try:
result = response.json()
except ValueError:
_logger.error("Poynt returned non-JSON response: %s", response.text[:500])
raise ValidationError(_("Poynt returned an invalid response."))
if response.status_code >= 400:
error_msg = result.get('message', result.get('developerMessage', 'Unknown error'))
_logger.error(
"Poynt API error %s: %s (request_id=%s)",
response.status_code, error_msg, request_id,
)
raise ValidationError(
_("Poynt API error (%(code)s): %(msg)s",
code=response.status_code, msg=error_msg)
)
return result
# === BUSINESS METHODS - INLINE FORM === #
def _poynt_get_inline_form_values(self, amount, currency, partner_id, is_validation,
payment_method_sudo=None, **kwargs):
"""Return a serialized JSON of values needed to render the inline payment form.
:param float amount: The payment amount.
:param recordset currency: The currency of the transaction.
:param int partner_id: The partner ID.
:param bool is_validation: Whether this is a validation (tokenization) operation.
:param recordset payment_method_sudo: The sudoed payment method record.
:return: The JSON-serialized inline form values.
:rtype: str
"""
self.ensure_one()
partner = self.env['res.partner'].browse(partner_id).exists()
minor_amount = poynt_utils.format_poynt_amount(amount, currency) if amount else 0
inline_form_values = {
'business_id': self.poynt_business_id,
'application_id': self.poynt_application_id,
'currency_name': currency.name if currency else 'USD',
'minor_amount': minor_amount,
'capture_method': 'manual' if self.capture_manually else 'automatic',
'is_test': self.state == 'test',
'billing_details': {
'name': partner.name or '',
'email': partner.email or '',
'phone': partner.phone or '',
'address': {
'line1': partner.street or '',
'line2': partner.street2 or '',
'city': partner.city or '',
'state': partner.state_id.code or '',
'country': partner.country_id.code or '',
'postal_code': partner.zip or '',
},
},
'is_tokenization_required': (
self.allow_tokenization
and self._is_tokenization_required(**kwargs)
and payment_method_sudo
and payment_method_sudo.support_tokenization
),
}
return json.dumps(inline_form_values)
# === ACTION METHODS === #
def action_poynt_test_connection(self):
"""Test the connection to Poynt by fetching business info.
:return: A notification action with the result.
:rtype: dict
"""
self.ensure_one()
try:
result = self._poynt_make_request('GET', '')
business_name = result.get('legalName', result.get('doingBusinessAs', 'Unknown'))
message = _(
"Connection successful. Business: %(name)s",
name=business_name,
)
notification_type = 'success'
except (ValidationError, UserError) as e:
message = _("Connection failed: %(error)s", error=str(e))
notification_type = 'danger'
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': message,
'sticky': False,
'type': notification_type,
},
}
def action_poynt_fetch_terminals(self):
"""Fetch terminal devices from Poynt and create/update local records.
:return: A notification action with the result.
:rtype: dict
"""
self.ensure_one()
try:
store_id = self.poynt_store_id
if store_id:
endpoint = f'stores/{store_id}/storeDevices'
else:
endpoint = 'storeDevices'
result = self._poynt_make_request('GET', endpoint)
devices = result if isinstance(result, list) else result.get('storeDevices', [])
terminal_model = self.env['poynt.terminal']
created = 0
updated = 0
for device in devices:
device_id = device.get('deviceId', '')
existing = terminal_model.search([
('device_id', '=', device_id),
('provider_id', '=', self.id),
], limit=1)
vals = {
'name': device.get('name', device_id),
'device_id': device_id,
'serial_number': device.get('serialNumber', ''),
'provider_id': self.id,
'status': 'online' if device.get('status') == 'ACTIVATED' else 'offline',
'store_id_poynt': device.get('storeId', ''),
}
if existing:
existing.write(vals)
updated += 1
else:
terminal_model.create(vals)
created += 1
message = _(
"Terminals synced: %(created)s created, %(updated)s updated.",
created=created, updated=updated,
)
notification_type = 'success'
except (ValidationError, UserError) as e:
message = _("Failed to fetch terminals: %(error)s", error=str(e))
notification_type = 'danger'
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': message,
'sticky': False,
'type': notification_type,
},
}