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:
gsinghpal
2026-02-24 04:21:05 -05:00
parent 84c009416e
commit 0e1aebe60b
26 changed files with 2735 additions and 9 deletions

17
fusion_poynt/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
def post_init_hook(env):
from odoo.addons.fusion_poynt import const
provider = env.ref('fusion_poynt.payment_provider_poynt', raise_if_not_found=False)
if provider:
provider._setup_provider('poynt')
def uninstall_hook(env):
provider = env.ref('fusion_poynt.payment_provider_poynt', raise_if_not_found=False)
if provider:
provider.write({'state': 'disabled', 'is_published': False})

View File

@@ -0,0 +1,29 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Payment Provider: Poynt',
'version': '19.0.1.0.0',
'category': 'Accounting/Payment Providers',
'sequence': 360,
'summary': "GoDaddy Poynt payment processing for cloud and terminal payments.",
'description': " ",
'depends': ['payment', 'account_payment'],
'data': [
'security/ir.model.access.csv',
'views/payment_provider_views.xml',
'views/payment_poynt_templates.xml',
'views/poynt_terminal_views.xml',
'data/payment_provider_data.xml',
],
'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook',
'assets': {
'web.assets_frontend': [
'fusion_poynt/static/src/interactions/**/*',
],
},
'author': 'Fusion Apps',
'license': 'LGPL-3',
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

84
fusion_poynt/const.py Normal file
View File

@@ -0,0 +1,84 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
API_BASE_URL = 'https://services.poynt.net'
API_VERSION = '1.2'
# Poynt test/sandbox environment
API_BASE_URL_TEST = 'https://services-eu.poynt.net'
TOKEN_ENDPOINT = '/token'
# Poynt OAuth authorization URL for merchant onboarding
OAUTH_AUTHORIZE_URL = 'https://poynt.net/applications/authorize'
OAUTH_SIGNOUT_URL = 'https://services.poynt.net/auth/signout'
# Poynt public key URL for JWT verification
POYNT_PUBLIC_KEY_URL = 'https://poynt.net'
DEFAULT_PAYMENT_METHOD_CODES = {
'card',
'visa',
'mastercard',
'amex',
'discover',
}
# Mapping of Poynt transaction statuses to Odoo payment transaction states.
STATUS_MAPPING = {
'authorized': ('AUTHORIZED',),
'done': ('CAPTURED', 'SETTLED'),
'cancel': ('VOIDED', 'CANCELED'),
'error': ('DECLINED', 'FAILED', 'REFUND_FAILED'),
'refund': ('REFUNDED',),
}
# Poynt transaction actions
TRANSACTION_ACTION = {
'authorize': 'AUTHORIZE',
'capture': 'CAPTURE',
'refund': 'REFUND',
'void': 'VOID',
'sale': 'SALE',
}
# Webhook event types we handle
HANDLED_WEBHOOK_EVENTS = [
'TRANSACTION_AUTHORIZED',
'TRANSACTION_CAPTURED',
'TRANSACTION_VOIDED',
'TRANSACTION_REFUNDED',
'TRANSACTION_DECLINED',
'TRANSACTION_UPDATED',
'ORDER_COMPLETED',
'ORDER_CANCELLED',
]
# Card brand mapping from Poynt scheme to Odoo payment method codes
CARD_BRAND_MAPPING = {
'VISA': 'visa',
'MASTERCARD': 'mastercard',
'AMERICAN_EXPRESS': 'amex',
'DISCOVER': 'discover',
'DINERS_CLUB': 'diners_club',
'JCB': 'jcb',
}
# Terminal statuses
TERMINAL_STATUS = {
'online': 'ONLINE',
'offline': 'OFFLINE',
'unknown': 'UNKNOWN',
}
# Poynt amounts are in cents (minor currency units)
CURRENCY_DECIMALS = {
'JPY': 0,
'KRW': 0,
}
# Sensitive keys that should be masked in logs
SENSITIVE_KEYS = {
'poynt_private_key',
'accessToken',
'refreshToken',
}

View File

@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main

View File

@@ -0,0 +1,518 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
import json
import logging
import pprint
from werkzeug.exceptions import Forbidden
from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.tools import mute_logger
from odoo.addons.fusion_poynt import const
from odoo.addons.fusion_poynt import utils as poynt_utils
_logger = logging.getLogger(__name__)
class PoyntController(http.Controller):
_return_url = '/payment/poynt/return'
_webhook_url = '/payment/poynt/webhook'
_terminal_callback_url = '/payment/poynt/terminal/callback'
_oauth_callback_url = '/payment/poynt/oauth/callback'
# === RETURN ROUTE === #
@http.route(_return_url, type='http', methods=['GET'], auth='public')
def poynt_return(self, **data):
"""Process the return from a Poynt payment flow.
The customer is redirected here after completing (or abandoning) a payment.
We look up the transaction by reference and fetch the latest status from Poynt.
"""
tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference(
'poynt', data,
)
if tx_sudo and tx_sudo.poynt_transaction_id:
try:
txn_data = tx_sudo.provider_id._poynt_make_request(
'GET',
f'transactions/{tx_sudo.poynt_transaction_id}',
)
payment_data = {
'reference': tx_sudo.reference,
'poynt_transaction_id': txn_data.get('id'),
'poynt_order_id': tx_sudo.poynt_order_id,
'poynt_status': txn_data.get('status', ''),
'funding_source': txn_data.get('fundingSource', {}),
}
tx_sudo._process('poynt', payment_data)
except ValidationError:
_logger.error(
"Failed to fetch Poynt transaction %s on return.",
tx_sudo.poynt_transaction_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 poynt_webhook(self):
"""Process webhook notifications from Poynt.
Poynt sends cloud hook events for transaction and order status changes.
We verify the payload, match it to an Odoo transaction, and update accordingly.
:return: An empty JSON response to acknowledge the notification.
:rtype: Response
"""
try:
raw_body = request.httprequest.data.decode('utf-8')
event = json.loads(raw_body)
except (ValueError, UnicodeDecodeError):
_logger.warning("Received invalid JSON from Poynt webhook")
return request.make_json_response({'status': 'error'}, status=400)
_logger.info(
"Poynt webhook notification received:\n%s",
pprint.pformat(event),
)
try:
event_type = event.get('eventType', event.get('type', ''))
resource = event.get('resource', {})
business_id = event.get('businessId', '')
if event_type not in const.HANDLED_WEBHOOK_EVENTS:
_logger.info("Ignoring unhandled Poynt event type: %s", event_type)
return request.make_json_response({'status': 'ignored'})
self._verify_webhook_signature(event, business_id)
if event_type.startswith('TRANSACTION_'):
self._handle_transaction_webhook(event_type, resource, business_id)
elif event_type.startswith('ORDER_'):
self._handle_order_webhook(event_type, resource, business_id)
except ValidationError:
_logger.exception("Unable to process Poynt webhook; acknowledging to avoid retries")
except Forbidden:
_logger.warning("Poynt webhook signature verification failed")
return request.make_json_response({'status': 'forbidden'}, status=403)
return request.make_json_response({'status': 'ok'})
def _handle_transaction_webhook(self, event_type, resource, business_id):
"""Process a transaction-related webhook event.
:param str event_type: The Poynt event type.
:param dict resource: The Poynt resource data from the webhook.
:param str business_id: The Poynt business ID.
"""
transaction_id = resource.get('id', '')
if not transaction_id:
_logger.warning("Transaction webhook missing transaction ID")
return
provider_sudo = request.env['payment.provider'].sudo().search([
('code', '=', 'poynt'),
('poynt_business_id', '=', business_id),
], limit=1)
if not provider_sudo:
_logger.warning("No Poynt provider found for business %s", business_id)
return
try:
txn_data = provider_sudo._poynt_make_request(
'GET', f'transactions/{transaction_id}',
)
except ValidationError:
_logger.error("Failed to fetch transaction %s from Poynt", transaction_id)
return
reference = txn_data.get('notes', '')
status = txn_data.get('status', '')
payment_data = {
'reference': reference,
'poynt_transaction_id': transaction_id,
'poynt_status': status,
'funding_source': txn_data.get('fundingSource', {}),
}
tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference(
'poynt', payment_data,
)
if not tx_sudo:
_logger.warning(
"No matching transaction for Poynt txn %s (ref: %s)",
transaction_id, reference,
)
return
if event_type == 'TRANSACTION_REFUNDED':
action = txn_data.get('action', '')
if action == 'REFUND':
parent_id = txn_data.get('parentId', '')
source_tx = request.env['payment.transaction'].sudo().search([
('provider_reference', '=', parent_id),
('provider_code', '=', 'poynt'),
], limit=1)
if source_tx:
refund_amount = poynt_utils.parse_poynt_amount(
txn_data.get('amounts', {}).get('transactionAmount', 0),
source_tx.currency_id,
)
existing_refund = source_tx.child_transaction_ids.filtered(
lambda t: t.provider_reference == transaction_id
)
if not existing_refund:
refund_tx = source_tx._create_child_transaction(
refund_amount, is_refund=True,
)
payment_data['reference'] = refund_tx.reference
refund_tx._process('poynt', payment_data)
return
tx_sudo._process('poynt', payment_data)
def _handle_order_webhook(self, event_type, resource, business_id):
"""Process an order-related webhook event.
:param str event_type: The Poynt event type.
:param dict resource: The Poynt resource data from the webhook.
:param str business_id: The Poynt business ID.
"""
order_id = resource.get('id', '')
if not order_id:
return
tx_sudo = request.env['payment.transaction'].sudo().search([
('poynt_order_id', '=', order_id),
('provider_code', '=', 'poynt'),
], limit=1)
if not tx_sudo:
_logger.info("No Odoo transaction found for Poynt order %s", order_id)
return
if event_type == 'ORDER_CANCELLED' and tx_sudo.state not in ('done', 'cancel', 'error'):
tx_sudo._set_canceled()
def _verify_webhook_signature(self, event, business_id):
"""Verify the webhook notification signature.
:param dict event: The webhook event data.
:param str business_id: The Poynt business ID.
:raises Forbidden: If signature verification fails.
"""
provider_sudo = request.env['payment.provider'].sudo().search([
('code', '=', 'poynt'),
('poynt_business_id', '=', business_id),
], limit=1)
if not provider_sudo or not provider_sudo.poynt_webhook_secret:
_logger.info("No webhook secret configured; skipping signature verification")
return
signature = request.httprequest.headers.get('X-Poynt-Webhook-Signature', '')
if not signature:
_logger.warning("Webhook missing X-Poynt-Webhook-Signature header")
return
raw_body = request.httprequest.data
expected_signature = hmac.new(
provider_sudo.poynt_webhook_secret.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
_logger.warning("Poynt webhook signature mismatch")
raise Forbidden()
# === TERMINAL CALLBACK ROUTE === #
@http.route(
_terminal_callback_url, type='http', methods=['POST'],
auth='public', csrf=False,
)
def poynt_terminal_callback(self, **data):
"""Handle callback from a Poynt terminal after a payment completes.
The terminal sends transaction results here after the customer
taps/inserts their card at the physical device.
:return: A JSON acknowledgement.
:rtype: Response
"""
try:
raw_body = request.httprequest.data.decode('utf-8')
event = json.loads(raw_body)
except (ValueError, UnicodeDecodeError):
return request.make_json_response({'status': 'error'}, status=400)
_logger.info(
"Poynt terminal callback received:\n%s",
pprint.pformat(event),
)
reference = event.get('referenceId', event.get('data', {}).get('referenceId', ''))
transaction_id = event.get('transactionId', event.get('data', {}).get('transactionId', ''))
if not reference and not transaction_id:
_logger.warning("Terminal callback missing reference and transaction ID")
return request.make_json_response({'status': 'error'}, status=400)
payment_data = {
'reference': reference,
'poynt_transaction_id': transaction_id,
}
tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference(
'poynt', payment_data,
)
if tx_sudo and transaction_id:
try:
txn_data = tx_sudo.provider_id._poynt_make_request(
'GET', f'transactions/{transaction_id}',
)
payment_data.update({
'poynt_status': txn_data.get('status', ''),
'funding_source': txn_data.get('fundingSource', {}),
'poynt_order_id': tx_sudo.poynt_order_id,
})
tx_sudo._process('poynt', payment_data)
except ValidationError:
_logger.error("Failed to process terminal callback for txn %s", transaction_id)
return request.make_json_response({'status': 'ok'})
# === OAUTH CALLBACK ROUTE === #
@http.route(_oauth_callback_url, type='http', methods=['GET'], auth='user')
def poynt_oauth_callback(self, **data):
"""Handle the OAuth2 authorization callback from Poynt.
After a merchant authorizes the application on poynt.net, they are
redirected here with an authorization code (JWT) and business ID.
:return: Redirect to the payment provider form.
:rtype: Response
"""
code = data.get('code', '')
status = data.get('status', '')
context = data.get('context', '')
business_id = data.get('businessId', '')
if status != 'AUTHORIZED':
_logger.warning("Poynt OAuth callback with status: %s", status)
return request.redirect('/odoo/settings')
if code:
try:
import jwt as pyjwt
decoded = pyjwt.decode(code, options={"verify_signature": False})
business_id = decoded.get('poynt.biz', business_id)
except Exception:
_logger.warning("Failed to decode Poynt OAuth JWT")
if business_id and context:
try:
provider_id = int(context)
provider = request.env['payment.provider'].browse(provider_id)
if provider.exists() and provider.code == 'poynt':
provider.sudo().write({
'poynt_business_id': business_id,
})
_logger.info(
"Poynt OAuth: linked business %s to provider %s",
business_id, provider_id,
)
except (ValueError, TypeError):
_logger.warning("Invalid provider context in Poynt OAuth callback: %s", context)
return request.redirect('/odoo/settings')
# === JSON-RPC ROUTES (called from frontend JS) === #
@http.route('/payment/poynt/terminals', type='jsonrpc', auth='public')
def poynt_get_terminals(self, provider_id=None, **kwargs):
"""Return available Poynt terminals for the given provider.
:param int provider_id: The payment provider ID.
:return: List of terminal dicts with id, name, status.
:rtype: list
"""
if not provider_id:
return []
terminals = request.env['poynt.terminal'].sudo().search([
('provider_id', '=', int(provider_id)),
('active', '=', True),
])
return [{
'id': t.id,
'name': t.name,
'status': t.status,
'device_id': t.device_id,
} for t in terminals]
@http.route('/payment/poynt/process_card', type='jsonrpc', auth='public')
def poynt_process_card(self, reference=None, poynt_order_id=None,
card_number=None, exp_month=None, exp_year=None,
cvv=None, cardholder_name=None, **kwargs):
"""Process a card payment through Poynt Cloud API.
The frontend sends card details which are passed to Poynt for
authorization. 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', '=', 'poynt'),
], limit=1)
if not tx_sudo:
return {'error': 'Transaction not found.'}
try:
funding_source = {
'type': 'CREDIT_DEBIT',
'card': {
'number': card_number,
'expirationMonth': int(exp_month),
'expirationYear': int(exp_year),
'cardHolderFullName': cardholder_name or '',
},
'verificationData': {
'cvData': cvv,
},
'entryDetails': {
'customerPresenceStatus': 'ECOMMERCE',
'entryMode': 'KEYED',
},
}
action = 'AUTHORIZE' if tx_sudo.provider_id.capture_manually else 'SALE'
minor_amount = poynt_utils.format_poynt_amount(
tx_sudo.amount, tx_sudo.currency_id,
)
txn_payload = {
'action': action,
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'tipAmount': 0,
'cashbackAmount': 0,
'currency': tx_sudo.currency_id.name,
},
'fundingSource': funding_source,
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
},
'notes': reference,
}
if poynt_order_id:
txn_payload['references'] = [{
'id': poynt_order_id,
'type': 'POYNT_ORDER',
}]
result = tx_sudo.provider_id._poynt_make_request(
'POST', 'transactions', payload=txn_payload,
)
transaction_id = result.get('id', '')
status = result.get('status', '')
tx_sudo.write({
'poynt_transaction_id': transaction_id,
'provider_reference': transaction_id,
})
payment_data = {
'reference': reference,
'poynt_transaction_id': transaction_id,
'poynt_order_id': poynt_order_id,
'poynt_status': status,
'funding_source': result.get('fundingSource', {}),
}
tx_sudo._process('poynt', 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.'}
@http.route('/payment/poynt/send_to_terminal', type='jsonrpc', auth='public')
def poynt_send_to_terminal(self, reference=None, terminal_id=None,
poynt_order_id=None, **kwargs):
"""Send a payment request to a Poynt terminal device.
:return: Dict with success status or error message.
:rtype: dict
"""
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', '=', 'poynt'),
], limit=1)
if not tx_sudo:
return {'error': 'Transaction not found.'}
terminal = request.env['poynt.terminal'].sudo().browse(int(terminal_id))
if not terminal.exists():
return {'error': 'Terminal not found.'}
try:
result = terminal.action_send_payment_to_terminal(
amount=tx_sudo.amount,
currency=tx_sudo.currency_id,
reference=reference,
order_id=poynt_order_id,
)
return {'success': True, 'message_id': result.get('id', '')}
except (ValidationError, Exception) as e:
return {'error': str(e)}
@http.route('/payment/poynt/terminal_status', type='jsonrpc', auth='public')
def poynt_terminal_status(self, reference=None, terminal_id=None, **kwargs):
"""Poll the status of a terminal payment.
:return: Dict with current payment status.
:rtype: dict
"""
if not reference:
return {'status': 'error', 'message': 'Missing reference.'}
terminal = request.env['poynt.terminal'].sudo().browse(int(terminal_id or 0))
if not terminal.exists():
return {'status': 'error', 'message': 'Terminal not found.'}
return terminal.action_check_terminal_payment_status(reference)

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment_provider_poynt" model="payment.provider">
<field name="name">Poynt</field>
<field name="code">poynt</field>
<field name="inline_form_view_id" ref="inline_form"/>
<field name="allow_tokenization">True</field>
<field name="state">disabled</field>
</record>
</odoo>

View 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

View 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,
},
}

View 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.")
)

View 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', ''),
}

View 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.'}

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,base.group_user,1,0,0,0
access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_poynt_terminal_user poynt.terminal.user model_poynt_terminal base.group_user 1 0 0 0
3 access_poynt_terminal_admin poynt.terminal.admin model_poynt_terminal base.group_system 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,374 @@
/** @odoo-module **/
import { _t } from '@web/core/l10n/translation';
import { patch } from '@web/core/utils/patch';
import { rpc } from '@web/core/network/rpc';
import { PaymentForm } from '@payment/interactions/payment_form';
patch(PaymentForm.prototype, {
setup() {
super.setup();
this.poyntFormData = {};
},
// #=== DOM MANIPULATION ===#
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'poynt') {
await super._prepareInlineForm(...arguments);
return;
}
if (flow === 'token') {
return;
}
this._setPaymentFlow('direct');
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const poyntContainer = inlineForm.querySelector('[name="o_poynt_payment_container"]');
if (!poyntContainer) {
return;
}
const rawValues = poyntContainer.dataset['poyntInlineFormValues'];
if (rawValues) {
this.poyntFormData = JSON.parse(rawValues);
}
this._setupCardFormatting(poyntContainer);
this._setupTerminalToggle(poyntContainer);
},
_setupCardFormatting(container) {
const cardInput = container.querySelector('#poynt_card_number');
if (cardInput) {
cardInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
let formatted = '';
for (let i = 0; i < value.length && i < 16; i++) {
if (i > 0 && i % 4 === 0) {
formatted += ' ';
}
formatted += value[i];
}
e.target.value = formatted;
});
}
const expiryInput = container.querySelector('#poynt_expiry');
if (expiryInput) {
expiryInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
if (value.length >= 2) {
value = value.substring(0, 2) + '/' + value.substring(2, 4);
}
e.target.value = value;
});
}
},
_setupTerminalToggle(container) {
const terminalCheckbox = container.querySelector('#poynt_use_terminal');
const terminalSelect = container.querySelector('#poynt_terminal_select_wrapper');
const cardFields = container.querySelectorAll(
'#poynt_card_number, #poynt_expiry, #poynt_cvv, #poynt_cardholder'
);
if (!terminalCheckbox) {
return;
}
terminalCheckbox.addEventListener('change', () => {
if (terminalCheckbox.checked) {
if (terminalSelect) {
terminalSelect.style.display = 'block';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'none';
f.removeAttribute('required');
});
this._loadTerminals(container);
} else {
if (terminalSelect) {
terminalSelect.style.display = 'none';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'block';
if (f.id !== 'poynt_cardholder') {
f.setAttribute('required', 'required');
}
});
}
});
},
async _loadTerminals(container) {
const selectEl = container.querySelector('#poynt_terminal_select');
if (!selectEl || selectEl.options.length > 1) {
return;
}
try {
const terminals = await rpc('/payment/poynt/terminals', {
provider_id: this.poyntFormData.provider_id,
});
selectEl.innerHTML = '';
if (terminals && terminals.length > 0) {
terminals.forEach(t => {
const option = document.createElement('option');
option.value = t.id;
option.textContent = `${t.name} (${t.status})`;
selectEl.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('No terminals available');
selectEl.appendChild(option);
}
} catch {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('Failed to load terminals');
selectEl.appendChild(option);
}
},
// #=== PAYMENT FLOW ===#
async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'poynt' || flow === 'token') {
await super._initiatePaymentFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#poynt_use_terminal');
if (useTerminal && useTerminal.checked) {
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
if (!terminalId) {
this._displayErrorDialog(
_t("Terminal Required"),
_t("Please select a terminal device."),
);
this._enableButton();
return;
}
} else {
const validationError = this._validateCardInputs(inlineForm);
if (validationError) {
this._displayErrorDialog(
_t("Invalid Card Details"),
validationError,
);
this._enableButton();
return;
}
}
await super._initiatePaymentFlow(...arguments);
},
_validateCardInputs(inlineForm) {
const cardNumber = inlineForm.querySelector('#poynt_card_number');
const expiry = inlineForm.querySelector('#poynt_expiry');
const cvv = inlineForm.querySelector('#poynt_cvv');
const cardDigits = cardNumber.value.replace(/\D/g, '');
if (cardDigits.length < 13 || cardDigits.length > 19) {
return _t("Please enter a valid card number.");
}
const expiryValue = expiry.value;
if (!/^\d{2}\/\d{2}$/.test(expiryValue)) {
return _t("Please enter a valid expiry date (MM/YY).");
}
const [month, year] = expiryValue.split('/').map(Number);
if (month < 1 || month > 12) {
return _t("Invalid expiry month.");
}
const now = new Date();
const expiryDate = new Date(2000 + year, month);
if (expiryDate <= now) {
return _t("Card has expired.");
}
const cvvValue = cvv.value.replace(/\D/g, '');
if (cvvValue.length < 3 || cvvValue.length > 4) {
return _t("Please enter a valid CVV.");
}
return null;
},
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
if (providerCode !== 'poynt') {
await super._processDirectFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#poynt_use_terminal');
if (useTerminal && useTerminal.checked) {
await this._processTerminalPayment(processingValues, inlineForm);
} else {
await this._processCardPayment(processingValues, inlineForm);
}
},
async _processCardPayment(processingValues, inlineForm) {
const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, '');
const expiry = inlineForm.querySelector('#poynt_expiry').value;
const cvv = inlineForm.querySelector('#poynt_cvv').value;
const cardholder = inlineForm.querySelector('#poynt_cardholder').value;
const [expMonth, expYear] = expiry.split('/').map(Number);
try {
const result = await rpc('/payment/poynt/process_card', {
reference: processingValues.reference,
poynt_order_id: processingValues.poynt_order_id,
card_number: cardNumber,
exp_month: expMonth,
exp_year: 2000 + expYear,
cvv: cvv,
cardholder_name: cardholder,
});
if (result.error) {
this._displayErrorDialog(
_t("Payment Failed"),
result.error,
);
this._enableButton();
return;
}
window.location.href = processingValues.return_url;
} catch (error) {
this._displayErrorDialog(
_t("Payment Processing Error"),
error.message || _t("An unexpected error occurred."),
);
this._enableButton();
}
},
async _processTerminalPayment(processingValues, inlineForm) {
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
try {
const result = await rpc('/payment/poynt/send_to_terminal', {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
poynt_order_id: processingValues.poynt_order_id,
});
if (result.error) {
this._displayErrorDialog(
_t("Terminal Payment Failed"),
result.error,
);
this._enableButton();
return;
}
this._showTerminalWaitingScreen(processingValues, terminalId);
} catch (error) {
this._displayErrorDialog(
_t("Terminal Error"),
error.message || _t("Failed to send payment to terminal."),
);
this._enableButton();
}
},
_showTerminalWaitingScreen(processingValues, terminalId) {
const container = document.querySelector('.o_poynt_payment_form');
if (container) {
container.innerHTML = `
<div class="text-center p-4">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5>${_t("Waiting for terminal payment...")}</h5>
<p class="text-muted">
${_t("Please complete the payment on the terminal device.")}
</p>
<p class="text-muted small" id="poynt_terminal_status">
${_t("Checking status...")}
</p>
</div>
`;
}
this._pollTerminalStatus(processingValues, terminalId);
},
async _pollTerminalStatus(processingValues, terminalId, attempt = 0) {
const maxAttempts = 60;
const pollInterval = 3000;
if (attempt >= maxAttempts) {
this._displayErrorDialog(
_t("Timeout"),
_t("Terminal payment timed out. Please check the device."),
);
this._enableButton();
return;
}
try {
const result = await rpc('/payment/poynt/terminal_status', {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
});
const statusEl = document.getElementById('poynt_terminal_status');
if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') {
if (statusEl) {
statusEl.textContent = _t("Payment completed! Redirecting...");
}
window.location.href = processingValues.return_url;
return;
}
if (result.status === 'DECLINED' || result.status === 'FAILED') {
this._displayErrorDialog(
_t("Payment Declined"),
_t("The payment was declined at the terminal."),
);
this._enableButton();
return;
}
if (statusEl) {
statusEl.textContent = _t("Status: ") + (result.status || _t("Pending"));
}
setTimeout(
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
pollInterval,
);
} catch {
setTimeout(
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
pollInterval,
);
}
},
});

View File

@@ -0,0 +1,136 @@
/** @odoo-module **/
import { _t } from '@web/core/l10n/translation';
import { rpc } from '@web/core/network/rpc';
import { Component, useState } from '@odoo/owl';
export class TerminalPaymentWidget extends Component {
static template = 'fusion_poynt.TerminalPaymentWidget';
static props = {
providerId: { type: Number },
amount: { type: Number },
currency: { type: String },
reference: { type: String },
orderId: { type: String, optional: true },
onComplete: { type: Function, optional: true },
onError: { type: Function, optional: true },
};
setup() {
this.state = useState({
terminals: [],
selectedTerminalId: null,
loading: false,
polling: false,
status: '',
message: '',
});
this._loadTerminals();
}
async _loadTerminals() {
this.state.loading = true;
try {
const result = await rpc('/payment/poynt/terminals', {
provider_id: this.props.providerId,
});
this.state.terminals = result || [];
if (this.state.terminals.length > 0) {
this.state.selectedTerminalId = this.state.terminals[0].id;
}
} catch {
this.state.message = _t('Failed to load terminal devices.');
} finally {
this.state.loading = false;
}
}
onTerminalChange(ev) {
this.state.selectedTerminalId = parseInt(ev.target.value);
}
async onSendToTerminal() {
if (!this.state.selectedTerminalId) {
this.state.message = _t('Please select a terminal.');
return;
}
this.state.loading = true;
this.state.message = '';
try {
const result = await rpc('/payment/poynt/send_to_terminal', {
reference: this.props.reference,
terminal_id: this.state.selectedTerminalId,
poynt_order_id: this.props.orderId || '',
});
if (result.error) {
this.state.message = result.error;
this.state.loading = false;
if (this.props.onError) {
this.props.onError(result.error);
}
return;
}
this.state.polling = true;
this.state.status = _t('Waiting for payment on terminal...');
this._pollStatus(0);
} catch (error) {
this.state.message = error.message || _t('Failed to send payment to terminal.');
this.state.loading = false;
if (this.props.onError) {
this.props.onError(this.state.message);
}
}
}
async _pollStatus(attempt) {
const maxAttempts = 60;
const pollInterval = 3000;
if (attempt >= maxAttempts) {
this.state.polling = false;
this.state.loading = false;
this.state.message = _t('Payment timed out. Please check the terminal.');
if (this.props.onError) {
this.props.onError(this.state.message);
}
return;
}
try {
const result = await rpc('/payment/poynt/terminal_status', {
reference: this.props.reference,
terminal_id: this.state.selectedTerminalId,
});
if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') {
this.state.polling = false;
this.state.loading = false;
this.state.status = _t('Payment completed!');
if (this.props.onComplete) {
this.props.onComplete(result);
}
return;
}
if (result.status === 'DECLINED' || result.status === 'FAILED') {
this.state.polling = false;
this.state.loading = false;
this.state.message = _t('Payment was declined.');
if (this.props.onError) {
this.props.onError(this.state.message);
}
return;
}
this.state.status = _t('Status: ') + (result.status || _t('Pending'));
} catch {
this.state.status = _t('Checking...');
}
setTimeout(() => this._pollStatus(attempt + 1), pollInterval);
}
}

251
fusion_poynt/utils.py Normal file
View File

@@ -0,0 +1,251 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import time
import uuid
from odoo.exceptions import ValidationError
from odoo.addons.fusion_poynt import const
def generate_request_id():
"""Generate a unique request ID for Poynt API idempotency."""
return str(uuid.uuid4())
def build_api_url(endpoint, business_id=None, store_id=None, is_test=False):
"""Build a full Poynt API URL for the given endpoint.
:param str endpoint: The API endpoint path (e.g., 'orders', 'transactions').
:param str business_id: The merchant's business UUID.
:param str store_id: The store UUID (optional, for store-scoped endpoints).
:param bool is_test: Whether to use the test environment.
:return: The full API URL.
:rtype: str
"""
base = const.API_BASE_URL_TEST if is_test else const.API_BASE_URL
if business_id and store_id:
return f"{base}/businesses/{business_id}/stores/{store_id}/{endpoint}"
elif business_id:
return f"{base}/businesses/{business_id}/{endpoint}"
return f"{base}/{endpoint}"
def build_api_headers(access_token, request_id=None):
"""Build the standard HTTP headers for a Poynt API request.
:param str access_token: The OAuth2 bearer token.
:param str request_id: Optional unique request ID for idempotency.
:return: The request headers dict.
:rtype: dict
"""
headers = {
'Content-Type': 'application/json',
'Api-Version': const.API_VERSION,
'Authorization': f'Bearer {access_token}',
}
if request_id:
headers['POYNT-REQUEST-ID'] = request_id
return headers
def create_self_signed_jwt(application_id, private_key_pem):
"""Create a self-signed JWT for Poynt OAuth2 token request.
The JWT is signed with the application's RSA private key and used
as the assertion in the JWT bearer grant type flow.
:param str application_id: The Poynt application ID (urn:aid:...).
:param str private_key_pem: PEM-encoded RSA private key string.
:return: The signed JWT string.
:rtype: str
:raises ValidationError: If JWT creation fails.
"""
try:
import jwt as pyjwt
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.backends import default_backend
except ImportError:
raise ValidationError(
"Required Python packages 'PyJWT' and 'cryptography' are not installed. "
"Install them with: pip install PyJWT cryptography"
)
try:
if isinstance(private_key_pem, bytes):
key_bytes = private_key_pem
else:
key_bytes = private_key_pem.encode('utf-8')
private_key = load_pem_private_key(key_bytes, password=None, backend=default_backend())
now = int(time.time())
payload = {
'iss': application_id,
'sub': application_id,
'aud': 'https://services.poynt.net',
'iat': now,
'exp': now + 300,
'jti': str(uuid.uuid4()),
}
token = pyjwt.encode(payload, private_key, algorithm='RS256')
return token
except Exception as e:
raise ValidationError(
f"Failed to create self-signed JWT for Poynt authentication: {e}"
)
def format_poynt_amount(amount, currency):
"""Convert a major currency amount to Poynt's minor units (cents).
:param float amount: The amount in major currency units.
:param recordset currency: The currency record.
:return: The amount in minor currency units (integer).
:rtype: int
"""
decimals = const.CURRENCY_DECIMALS.get(currency.name, 2)
return int(round(amount * (10 ** decimals)))
def parse_poynt_amount(minor_amount, currency):
"""Convert Poynt's minor currency units back to major units.
:param int minor_amount: The amount in minor currency units.
:param recordset currency: The currency record.
:return: The amount in major currency units.
:rtype: float
"""
decimals = const.CURRENCY_DECIMALS.get(currency.name, 2)
return minor_amount / (10 ** decimals)
def extract_card_details(funding_source):
"""Extract card details from a Poynt funding source object.
:param dict funding_source: The Poynt fundingSource object from a transaction.
:return: Dict with card brand, last4, expiration, and card type.
:rtype: dict
"""
if not funding_source or 'card' not in funding_source:
return {}
card = funding_source['card']
brand_code = const.CARD_BRAND_MAPPING.get(
card.get('type', ''), 'card'
)
return {
'brand': brand_code,
'last4': card.get('numberLast4', ''),
'exp_month': card.get('expirationMonth'),
'exp_year': card.get('expirationYear'),
'card_holder': card.get('cardHolderFullName', ''),
'card_id': card.get('cardId', ''),
'number_first6': card.get('numberFirst6', ''),
}
def get_poynt_status(status_str):
"""Map a Poynt transaction status string to an Odoo transaction state.
:param str status_str: The Poynt transaction status.
:return: The corresponding Odoo payment state.
:rtype: str
"""
for odoo_state, poynt_statuses in const.STATUS_MAPPING.items():
if status_str in poynt_statuses:
return odoo_state
return 'error'
def build_order_payload(reference, amount, currency, items=None, notes=''):
"""Build a Poynt order creation payload.
:param str reference: The Odoo transaction reference.
:param float amount: The order total in major currency units.
:param recordset currency: The currency record.
:param list items: Optional list of order item dicts.
:param str notes: Optional order notes.
:return: The Poynt-formatted order payload.
:rtype: dict
"""
minor_amount = format_poynt_amount(amount, currency)
if not items:
items = [{
'name': reference,
'quantity': 1,
'unitPrice': minor_amount,
'tax': 0,
'status': 'ORDERED',
'unitOfMeasure': 'EACH',
}]
return {
'items': items,
'amounts': {
'subTotal': minor_amount,
'discountTotal': 0,
'feeTotal': 0,
'taxTotal': 0,
'netTotal': minor_amount,
'currency': currency.name,
},
'context': {
'source': 'WEB',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
},
'statuses': {
'status': 'OPENED',
},
'notes': notes or reference,
}
def build_transaction_payload(
action, amount, currency, order_id=None, reference='', funding_source=None
):
"""Build a Poynt transaction payload for charge/auth/capture.
:param str action: The transaction action (AUTHORIZE, SALE, CAPTURE, etc.).
:param float amount: The amount in major currency units.
:param recordset currency: The currency record.
:param str order_id: The Poynt order UUID (optional).
:param str reference: The Odoo transaction reference.
:param dict funding_source: The funding source / card data (optional).
:return: The Poynt-formatted transaction payload.
:rtype: dict
"""
minor_amount = format_poynt_amount(amount, currency)
payload = {
'action': action,
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'tipAmount': 0,
'cashbackAmount': 0,
'currency': currency.name,
},
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
},
'notes': reference,
}
if order_id:
payload['references'] = [{
'id': order_id,
'type': 'POYNT_ORDER',
}]
if funding_source:
payload['fundingSource'] = funding_source
return payload

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inline payment form template for Poynt -->
<template id="inline_form">
<t t-set="inline_form_values"
t-value="provider_sudo._poynt_get_inline_form_values(
amount,
currency,
partner_id,
mode == 'validation',
payment_method_sudo=pm_sudo,
)"
/>
<div name="o_poynt_payment_container"
class="o_poynt_payment_form"
t-att-data-poynt-inline-form-values="inline_form_values">
<!-- Card number input -->
<div class="mb-3">
<label class="form-label" for="poynt_card_number">Card Number</label>
<input type="text" class="form-control"
id="poynt_card_number"
name="card_number"
placeholder="4111 1111 1111 1111"
maxlength="19"
autocomplete="cc-number"
required="required"/>
</div>
<!-- Expiry and CVV row -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label" for="poynt_expiry">Expiry Date</label>
<input type="text" class="form-control"
id="poynt_expiry"
name="expiry"
placeholder="MM/YY"
maxlength="5"
autocomplete="cc-exp"
required="required"/>
</div>
<div class="col-6">
<label class="form-label" for="poynt_cvv">CVV</label>
<input type="password" class="form-control"
id="poynt_cvv"
name="cvv"
placeholder="123"
maxlength="4"
autocomplete="cc-csc"
required="required"/>
</div>
</div>
<!-- Cardholder name -->
<div class="mb-3">
<label class="form-label" for="poynt_cardholder">Cardholder Name</label>
<input type="text" class="form-control"
id="poynt_cardholder"
name="cardholder_name"
placeholder="John Doe"
autocomplete="cc-name"/>
</div>
<!-- Terminal payment option -->
<div class="mb-3 o_poynt_terminal_section" style="display:none;">
<hr/>
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="poynt_use_terminal" name="use_terminal"/>
<label class="form-check-label" for="poynt_use_terminal">
Pay at Terminal
</label>
</div>
<div id="poynt_terminal_select_wrapper" style="display:none;" class="mt-2">
<label class="form-label" for="poynt_terminal_select">Select Terminal</label>
<select class="form-select" id="poynt_terminal_select" name="terminal_id">
</select>
</div>
</div>
</div>
</template>
</odoo>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_provider_form" model="ir.ui.view">
<field name="name">Poynt Provider Form</field>
<field name="model">payment.provider</field>
<field name="inherit_id" ref="payment.payment_provider_form"/>
<field name="arch" type="xml">
<group name="provider_credentials" position="inside">
<group invisible="code != 'poynt'" name="poynt_credentials">
<field name="poynt_application_id"
required="code == 'poynt' and state != 'disabled'"
placeholder="urn:aid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"/>
<field name="poynt_private_key"
required="code == 'poynt' and state != 'disabled'"
widget="text"
placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----"/>
<field name="poynt_business_id"
required="code == 'poynt' and state != 'disabled'"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"/>
<field name="poynt_store_id"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"/>
<label for="poynt_webhook_secret"/>
<div class="o_row" col="2">
<field name="poynt_webhook_secret" password="True"/>
</div>
</group>
</group>
<group name="provider_credentials" position="after">
<group string="Poynt Actions"
invisible="code != 'poynt'" name="poynt_actions"
col="4">
<button string="Test Connection"
type="object"
name="action_poynt_test_connection"
class="btn-primary"
invisible="not poynt_application_id or not poynt_private_key or not poynt_business_id"
colspan="2"/>
<button string="Fetch Terminals"
type="object"
name="action_poynt_fetch_terminals"
class="btn-secondary"
invisible="not poynt_business_id"
colspan="2"/>
</group>
</group>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Poynt Terminal Tree View -->
<record id="poynt_terminal_view_list" model="ir.ui.view">
<field name="name">poynt.terminal.list</field>
<field name="model">poynt.terminal</field>
<field name="arch" type="xml">
<list string="Poynt Terminals">
<field name="name"/>
<field name="device_id"/>
<field name="serial_number"/>
<field name="provider_id"/>
<field name="store_id_poynt"/>
<field name="status" widget="badge"
decoration-success="status == 'online'"
decoration-danger="status == 'offline'"
decoration-warning="status == 'unknown'"/>
<field name="last_seen"/>
</list>
</field>
</record>
<!-- Poynt Terminal Form View -->
<record id="poynt_terminal_view_form" model="ir.ui.view">
<field name="name">poynt.terminal.form</field>
<field name="model">poynt.terminal</field>
<field name="arch" type="xml">
<form string="Poynt Terminal">
<header>
<button string="Refresh Status"
type="object"
name="action_refresh_status"
class="btn-primary"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="Terminal Name"/>
</h1>
</div>
<group>
<group string="Device Information">
<field name="device_id"/>
<field name="serial_number"/>
<field name="status" widget="badge"
decoration-success="status == 'online'"
decoration-danger="status == 'offline'"
decoration-warning="status == 'unknown'"/>
<field name="last_seen"/>
</group>
<group string="Configuration">
<field name="provider_id"/>
<field name="store_id_poynt"/>
<field name="active"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Poynt Terminal Search View -->
<record id="poynt_terminal_view_search" model="ir.ui.view">
<field name="name">poynt.terminal.search</field>
<field name="model">poynt.terminal</field>
<field name="arch" type="xml">
<search string="Poynt Terminals">
<field name="name"/>
<field name="device_id"/>
<field name="serial_number"/>
<field name="provider_id"/>
<filter string="Online" name="online"
domain="[('status', '=', 'online')]"/>
<filter string="Offline" name="offline"
domain="[('status', '=', 'offline')]"/>
<separator/>
<filter string="Status" name="group_status"
context="{'group_by': 'status'}"/>
<filter string="Provider" name="group_provider"
context="{'group_by': 'provider_id'}"/>
</search>
</field>
</record>
<!-- Poynt Terminal Action -->
<record id="action_poynt_terminal" model="ir.actions.act_window">
<field name="name">Poynt Terminals</field>
<field name="res_model">poynt.terminal</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="poynt_terminal_view_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No Poynt terminals configured yet.
</p>
<p>
Click "Fetch Terminals" on your Poynt payment provider to sync
terminal devices from the Poynt Cloud, or add one manually.
</p>
</field>
</record>
<!-- Menu entry under Payment Providers -->
<menuitem id="menu_poynt_terminal"
name="Poynt Terminals"
parent="account_payment.payment_provider_menu"
action="action_poynt_terminal"
sequence="30"/>
</odoo>