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>
This commit is contained in:
6
fusion_poynt/models/__init__.py
Normal file
6
fusion_poynt/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import payment_provider
|
||||
from . import payment_token
|
||||
from . import payment_transaction
|
||||
from . import poynt_terminal
|
||||
377
fusion_poynt/models/payment_provider.py
Normal file
377
fusion_poynt/models/payment_provider.py
Normal file
@@ -0,0 +1,377 @@
|
||||
# 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,
|
||||
},
|
||||
}
|
||||
55
fusion_poynt/models/payment_token.py
Normal file
55
fusion_poynt/models/payment_token.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentToken(models.Model):
|
||||
_inherit = 'payment.token'
|
||||
|
||||
poynt_card_id = fields.Char(
|
||||
string="Poynt Card ID",
|
||||
help="The unique card identifier stored on the Poynt platform.",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
def _poynt_validate_stored_card(self):
|
||||
"""Validate that the stored card is still usable on Poynt.
|
||||
|
||||
Fetches the card details from Poynt to confirm the card ID is valid
|
||||
and the card is still active.
|
||||
|
||||
:return: True if the card is valid.
|
||||
:rtype: bool
|
||||
:raises ValidationError: If the card cannot be validated.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.poynt_card_id:
|
||||
raise ValidationError(
|
||||
_("No Poynt card ID found on this payment token.")
|
||||
)
|
||||
|
||||
try:
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'GET',
|
||||
f'cards/{self.poynt_card_id}',
|
||||
)
|
||||
status = result.get('status', '')
|
||||
if status != 'ACTIVE':
|
||||
raise ValidationError(
|
||||
_("The stored card is no longer active on Poynt (status: %(status)s).",
|
||||
status=status)
|
||||
)
|
||||
return True
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to validate Poynt card %s: %s", self.poynt_card_id, e)
|
||||
raise ValidationError(
|
||||
_("Unable to validate the stored card with Poynt.")
|
||||
)
|
||||
386
fusion_poynt/models/payment_transaction.py
Normal file
386
fusion_poynt/models/payment_transaction.py
Normal file
@@ -0,0 +1,386 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.urls import urljoin as url_join
|
||||
|
||||
from odoo.addons.fusion_poynt import const
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
from odoo.addons.fusion_poynt.controllers.main import PoyntController
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
poynt_order_id = fields.Char(
|
||||
string="Poynt Order ID",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
poynt_transaction_id = fields.Char(
|
||||
string="Poynt Transaction ID",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# === BUSINESS METHODS - PAYMENT FLOW === #
|
||||
|
||||
def _get_specific_processing_values(self, processing_values):
|
||||
"""Override of payment to return Poynt-specific processing values.
|
||||
|
||||
For direct (online) payments we create a Poynt order upfront and return
|
||||
identifiers plus the return URL so the frontend JS can complete the flow.
|
||||
"""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._get_specific_processing_values(processing_values)
|
||||
|
||||
if self.operation == 'online_token':
|
||||
return {}
|
||||
|
||||
poynt_data = self._poynt_create_order_and_authorize()
|
||||
|
||||
base_url = self.provider_id.get_base_url()
|
||||
return_url = url_join(
|
||||
base_url,
|
||||
f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}',
|
||||
)
|
||||
|
||||
return {
|
||||
'poynt_order_id': poynt_data.get('order_id', ''),
|
||||
'poynt_transaction_id': poynt_data.get('transaction_id', ''),
|
||||
'return_url': return_url,
|
||||
'business_id': self.provider_id.poynt_business_id,
|
||||
'is_test': self.provider_id.state == 'test',
|
||||
}
|
||||
|
||||
def _send_payment_request(self):
|
||||
"""Override of `payment` to send a payment request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_payment_request()
|
||||
|
||||
if self.operation in ('online_token', 'offline'):
|
||||
return self._poynt_process_token_payment()
|
||||
|
||||
poynt_data = self._poynt_create_order_and_authorize()
|
||||
if poynt_data:
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_order_id': poynt_data.get('order_id'),
|
||||
'poynt_transaction_id': poynt_data.get('transaction_id'),
|
||||
'poynt_status': poynt_data.get('status', 'AUTHORIZED'),
|
||||
'funding_source': poynt_data.get('funding_source', {}),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
|
||||
def _poynt_create_order_and_authorize(self):
|
||||
"""Create a Poynt order and authorize the transaction.
|
||||
|
||||
:return: Dict with order_id, transaction_id, status, and funding_source.
|
||||
:rtype: dict
|
||||
"""
|
||||
try:
|
||||
order_payload = poynt_utils.build_order_payload(
|
||||
self.reference, self.amount, self.currency_id,
|
||||
)
|
||||
order_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'orders', payload=order_payload,
|
||||
)
|
||||
order_id = order_result.get('id', '')
|
||||
self.poynt_order_id = order_id
|
||||
|
||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
||||
txn_payload = poynt_utils.build_transaction_payload(
|
||||
action=action,
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
order_id=order_id,
|
||||
reference=self.reference,
|
||||
)
|
||||
txn_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=txn_payload,
|
||||
)
|
||||
|
||||
transaction_id = txn_result.get('id', '')
|
||||
self.poynt_transaction_id = transaction_id
|
||||
self.provider_reference = transaction_id
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'transaction_id': transaction_id,
|
||||
'status': txn_result.get('status', ''),
|
||||
'funding_source': txn_result.get('fundingSource', {}),
|
||||
}
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
return {}
|
||||
|
||||
def _poynt_process_token_payment(self):
|
||||
"""Process a payment using a stored token (card on file).
|
||||
|
||||
For token-based payments we send a SALE or AUTHORIZE using the
|
||||
stored card ID from the payment token.
|
||||
"""
|
||||
try:
|
||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
||||
|
||||
funding_source = {
|
||||
'type': 'CREDIT_DEBIT',
|
||||
'card': {
|
||||
'cardId': self.token_id.poynt_card_id,
|
||||
},
|
||||
}
|
||||
|
||||
order_payload = poynt_utils.build_order_payload(
|
||||
self.reference, self.amount, self.currency_id,
|
||||
)
|
||||
order_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'orders', payload=order_payload,
|
||||
)
|
||||
order_id = order_result.get('id', '')
|
||||
self.poynt_order_id = order_id
|
||||
|
||||
txn_payload = poynt_utils.build_transaction_payload(
|
||||
action=action,
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
order_id=order_id,
|
||||
reference=self.reference,
|
||||
funding_source=funding_source,
|
||||
)
|
||||
txn_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=txn_payload,
|
||||
)
|
||||
|
||||
transaction_id = txn_result.get('id', '')
|
||||
self.poynt_transaction_id = transaction_id
|
||||
self.provider_reference = transaction_id
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_order_id': order_id,
|
||||
'poynt_transaction_id': transaction_id,
|
||||
'poynt_status': txn_result.get('status', ''),
|
||||
'funding_source': txn_result.get('fundingSource', {}),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_refund_request(self):
|
||||
"""Override of `payment` to send a refund request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_refund_request()
|
||||
|
||||
source_tx = self.source_transaction_id
|
||||
refund_amount = abs(self.amount)
|
||||
minor_amount = poynt_utils.format_poynt_amount(refund_amount, self.currency_id)
|
||||
|
||||
try:
|
||||
refund_payload = {
|
||||
'action': 'REFUND',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'amounts': {
|
||||
'transactionAmount': minor_amount,
|
||||
'orderAmount': minor_amount,
|
||||
'currency': self.currency_id.name,
|
||||
},
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
'notes': f'Refund for {source_tx.reference}',
|
||||
}
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=refund_payload,
|
||||
)
|
||||
|
||||
self.provider_reference = result.get('id', '')
|
||||
self.poynt_transaction_id = result.get('id', '')
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_transaction_id': result.get('id'),
|
||||
'poynt_status': result.get('status', 'REFUNDED'),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_capture_request(self):
|
||||
"""Override of `payment` to send a capture request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_capture_request()
|
||||
|
||||
source_tx = self.source_transaction_id
|
||||
minor_amount = poynt_utils.format_poynt_amount(self.amount, self.currency_id)
|
||||
|
||||
try:
|
||||
capture_payload = {
|
||||
'action': 'CAPTURE',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'amounts': {
|
||||
'transactionAmount': minor_amount,
|
||||
'orderAmount': minor_amount,
|
||||
'currency': self.currency_id.name,
|
||||
},
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
}
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=capture_payload,
|
||||
)
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_transaction_id': result.get('id'),
|
||||
'poynt_status': result.get('status', 'CAPTURED'),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_void_request(self):
|
||||
"""Override of `payment` to send a void request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_void_request()
|
||||
|
||||
source_tx = self.source_transaction_id
|
||||
|
||||
try:
|
||||
void_payload = {
|
||||
'action': 'VOID',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
}
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=void_payload,
|
||||
)
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_transaction_id': result.get('id'),
|
||||
'poynt_status': result.get('status', 'VOIDED'),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
# === BUSINESS METHODS - NOTIFICATION PROCESSING === #
|
||||
|
||||
@api.model
|
||||
def _search_by_reference(self, provider_code, payment_data):
|
||||
"""Override of payment to find the transaction based on Poynt data."""
|
||||
if provider_code != 'poynt':
|
||||
return super()._search_by_reference(provider_code, payment_data)
|
||||
|
||||
reference = payment_data.get('reference')
|
||||
if reference:
|
||||
tx = self.search([
|
||||
('reference', '=', reference),
|
||||
('provider_code', '=', 'poynt'),
|
||||
])
|
||||
else:
|
||||
poynt_txn_id = payment_data.get('poynt_transaction_id')
|
||||
if poynt_txn_id:
|
||||
tx = self.search([
|
||||
('poynt_transaction_id', '=', poynt_txn_id),
|
||||
('provider_code', '=', 'poynt'),
|
||||
])
|
||||
else:
|
||||
_logger.warning("Received Poynt data with no reference or transaction ID")
|
||||
tx = self
|
||||
|
||||
if not tx:
|
||||
_logger.warning(
|
||||
"No transaction found matching Poynt reference %s", reference,
|
||||
)
|
||||
|
||||
return tx
|
||||
|
||||
def _apply_updates(self, payment_data):
|
||||
"""Override of `payment` to update the transaction based on Poynt data."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._apply_updates(payment_data)
|
||||
|
||||
poynt_txn_id = payment_data.get('poynt_transaction_id')
|
||||
if poynt_txn_id:
|
||||
self.provider_reference = poynt_txn_id
|
||||
self.poynt_transaction_id = poynt_txn_id
|
||||
|
||||
poynt_order_id = payment_data.get('poynt_order_id')
|
||||
if poynt_order_id:
|
||||
self.poynt_order_id = poynt_order_id
|
||||
|
||||
funding_source = payment_data.get('funding_source', {})
|
||||
if funding_source:
|
||||
card_details = poynt_utils.extract_card_details(funding_source)
|
||||
if card_details.get('brand'):
|
||||
payment_method = self.env['payment.method']._get_from_code(
|
||||
card_details['brand'],
|
||||
mapping=const.CARD_BRAND_MAPPING,
|
||||
)
|
||||
if payment_method:
|
||||
self.payment_method_id = payment_method
|
||||
|
||||
status = payment_data.get('poynt_status', '')
|
||||
if not status:
|
||||
self._set_error(_("Received data with missing transaction status."))
|
||||
return
|
||||
|
||||
odoo_state = poynt_utils.get_poynt_status(status)
|
||||
|
||||
if odoo_state == 'authorized':
|
||||
self._set_authorized()
|
||||
elif odoo_state == 'done':
|
||||
self._set_done()
|
||||
if self.operation == 'refund':
|
||||
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
|
||||
elif odoo_state == 'cancel':
|
||||
self._set_canceled()
|
||||
elif odoo_state == 'refund':
|
||||
self._set_done()
|
||||
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
|
||||
elif odoo_state == 'error':
|
||||
error_msg = payment_data.get('error_message', _("Payment was declined by Poynt."))
|
||||
self._set_error(error_msg)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Received unknown Poynt status (%s) for transaction %s.",
|
||||
status, self.reference,
|
||||
)
|
||||
self._set_error(
|
||||
_("Received data with unrecognized status: %s.", status)
|
||||
)
|
||||
|
||||
def _extract_token_values(self, payment_data):
|
||||
"""Override of `payment` to return token data based on Poynt data."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._extract_token_values(payment_data)
|
||||
|
||||
funding_source = payment_data.get('funding_source', {})
|
||||
card_details = poynt_utils.extract_card_details(funding_source)
|
||||
|
||||
if not card_details:
|
||||
_logger.warning(
|
||||
"Tokenization requested but no card data in payment response."
|
||||
)
|
||||
return {}
|
||||
|
||||
return {
|
||||
'payment_details': card_details.get('last4', ''),
|
||||
'poynt_card_id': card_details.get('card_id', ''),
|
||||
}
|
||||
202
fusion_poynt/models/poynt_terminal.py
Normal file
202
fusion_poynt/models/poynt_terminal.py
Normal file
@@ -0,0 +1,202 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PoyntTerminal(models.Model):
|
||||
_name = 'poynt.terminal'
|
||||
_description = 'Poynt Terminal Device'
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string="Terminal Name",
|
||||
required=True,
|
||||
)
|
||||
device_id = fields.Char(
|
||||
string="Device ID",
|
||||
help="The Poynt device identifier (urn:tid:...).",
|
||||
required=True,
|
||||
copy=False,
|
||||
)
|
||||
serial_number = fields.Char(
|
||||
string="Serial Number",
|
||||
copy=False,
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'payment.provider',
|
||||
string="Payment Provider",
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
domain="[('code', '=', 'poynt')]",
|
||||
)
|
||||
store_id_poynt = fields.Char(
|
||||
string="Poynt Store ID",
|
||||
help="The Poynt store UUID this terminal belongs to.",
|
||||
)
|
||||
status = fields.Selection(
|
||||
selection=[
|
||||
('online', "Online"),
|
||||
('offline', "Offline"),
|
||||
('unknown', "Unknown"),
|
||||
],
|
||||
string="Status",
|
||||
default='unknown',
|
||||
readonly=True,
|
||||
)
|
||||
last_seen = fields.Datetime(
|
||||
string="Last Seen",
|
||||
readonly=True,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
default=True,
|
||||
)
|
||||
|
||||
_unique_device_provider = models.Constraint(
|
||||
'UNIQUE(device_id, provider_id)',
|
||||
'A terminal with this device ID already exists for this provider.',
|
||||
)
|
||||
|
||||
# === BUSINESS METHODS === #
|
||||
|
||||
def action_refresh_status(self):
|
||||
"""Refresh the terminal status from Poynt Cloud."""
|
||||
for terminal in self:
|
||||
try:
|
||||
store_id = terminal.store_id_poynt or terminal.provider_id.poynt_store_id
|
||||
if store_id:
|
||||
endpoint = f'stores/{store_id}/storeDevices/{terminal.device_id}'
|
||||
else:
|
||||
endpoint = f'storeDevices/{terminal.device_id}'
|
||||
|
||||
result = terminal.provider_id._poynt_make_request('GET', endpoint)
|
||||
poynt_status = result.get('status', 'UNKNOWN')
|
||||
|
||||
if poynt_status == 'ACTIVATED':
|
||||
terminal.status = 'online'
|
||||
elif poynt_status in ('DEACTIVATED', 'INACTIVE'):
|
||||
terminal.status = 'offline'
|
||||
else:
|
||||
terminal.status = 'unknown'
|
||||
|
||||
terminal.last_seen = fields.Datetime.now()
|
||||
except (ValidationError, UserError) as e:
|
||||
_logger.warning(
|
||||
"Failed to refresh status for terminal %s: %s",
|
||||
terminal.device_id, e,
|
||||
)
|
||||
terminal.status = 'unknown'
|
||||
|
||||
def action_send_payment_to_terminal(self, amount, currency, reference, order_id=None):
|
||||
"""Push a payment request to the physical Poynt terminal.
|
||||
|
||||
This sends a cloud message to the terminal device instructing it
|
||||
to start a payment collection for the given amount.
|
||||
|
||||
:param float amount: The payment amount in major currency units.
|
||||
:param recordset currency: The currency record.
|
||||
:param str reference: The Odoo payment reference.
|
||||
:param str order_id: Optional Poynt order UUID to link.
|
||||
:return: The Poynt cloud message response.
|
||||
:rtype: dict
|
||||
:raises UserError: If the terminal is offline.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if self.status == 'offline':
|
||||
raise UserError(
|
||||
_("Terminal '%(name)s' is offline. Please check the device.",
|
||||
name=self.name)
|
||||
)
|
||||
|
||||
minor_amount = poynt_utils.format_poynt_amount(amount, currency)
|
||||
|
||||
payment_request = {
|
||||
'amount': minor_amount,
|
||||
'currency': currency.name,
|
||||
'referenceId': reference,
|
||||
'callbackUrl': self._get_terminal_callback_url(),
|
||||
'skipReceiptScreen': False,
|
||||
'debit': True,
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payment_request['orderId'] = order_id
|
||||
|
||||
try:
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST',
|
||||
f'cloudMessages',
|
||||
payload={
|
||||
'deviceId': self.device_id,
|
||||
'ttl': 300,
|
||||
'serialNum': self.serial_number or '',
|
||||
'data': {
|
||||
'action': 'sale',
|
||||
'purchaseAmount': minor_amount,
|
||||
'tipAmount': 0,
|
||||
'currency': currency.name,
|
||||
'referenceId': reference,
|
||||
'callbackUrl': self._get_terminal_callback_url(),
|
||||
},
|
||||
},
|
||||
)
|
||||
_logger.info(
|
||||
"Payment request sent to terminal %s for %s %s (ref: %s)",
|
||||
self.device_id, amount, currency.name, reference,
|
||||
)
|
||||
return result
|
||||
except (ValidationError, UserError) as e:
|
||||
_logger.error(
|
||||
"Failed to send payment to terminal %s: %s",
|
||||
self.device_id, e,
|
||||
)
|
||||
raise
|
||||
|
||||
def _get_terminal_callback_url(self):
|
||||
"""Build the callback URL for terminal payment completion.
|
||||
|
||||
:return: The full callback URL.
|
||||
:rtype: str
|
||||
"""
|
||||
base_url = self.provider_id.get_base_url()
|
||||
return f"{base_url}/payment/poynt/terminal/callback"
|
||||
|
||||
def action_check_terminal_payment_status(self, reference):
|
||||
"""Poll for the status of a terminal payment.
|
||||
|
||||
:param str reference: The Odoo transaction reference.
|
||||
:return: Dict with status and transaction data if completed.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
txn_result = self.provider_id._poynt_make_request(
|
||||
'GET',
|
||||
'transactions',
|
||||
params={
|
||||
'notes': reference,
|
||||
'limit': 1,
|
||||
},
|
||||
)
|
||||
|
||||
transactions = txn_result.get('transactions', [])
|
||||
if not transactions:
|
||||
return {'status': 'pending', 'message': 'Waiting for terminal response...'}
|
||||
|
||||
txn = transactions[0]
|
||||
return {
|
||||
'status': txn.get('status', 'UNKNOWN'),
|
||||
'transaction_id': txn.get('id', ''),
|
||||
'funding_source': txn.get('fundingSource', {}),
|
||||
'amounts': txn.get('amounts', {}),
|
||||
}
|
||||
except (ValidationError, UserError):
|
||||
return {'status': 'error', 'message': 'Failed to check payment status.'}
|
||||
Reference in New Issue
Block a user