- 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>
519 lines
19 KiB
Python
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)
|