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

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

519 lines
19 KiB
Python

# 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)