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:
@@ -169,7 +169,6 @@ class FusionTechnicianTask(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
scheduled_date = fields.Date(
|
||||
string='Scheduled Date',
|
||||
required=True,
|
||||
tracking=True,
|
||||
default=fields.Date.context_today,
|
||||
index=True,
|
||||
@@ -265,6 +264,7 @@ class FusionTechnicianTask(models.Model):
|
||||
# STATUS
|
||||
# ------------------------------------------------------------------
|
||||
status = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
('scheduled', 'Scheduled'),
|
||||
('en_route', 'En Route'),
|
||||
('in_progress', 'In Progress'),
|
||||
@@ -851,6 +851,7 @@ class FusionTechnicianTask(models.Model):
|
||||
@api.depends('status')
|
||||
def _compute_color(self):
|
||||
color_map = {
|
||||
'pending': 5, # purple
|
||||
'scheduled': 0, # grey
|
||||
'en_route': 4, # blue
|
||||
'in_progress': 2, # orange
|
||||
@@ -2126,7 +2127,7 @@ class FusionTechnicianTask(models.Model):
|
||||
'time_start', 'time_start_display', 'time_end_display',
|
||||
'status', 'scheduled_date', 'travel_time_minutes',
|
||||
'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'],
|
||||
order='scheduled_date asc, time_start asc',
|
||||
order='scheduled_date asc NULLS LAST, time_start asc',
|
||||
limit=500,
|
||||
)
|
||||
locations = self.env['fusion.technician.location'].get_latest_locations()
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────
|
||||
const STATUS_COLORS = {
|
||||
pending: "#f59e0b",
|
||||
scheduled: "#3b82f6",
|
||||
en_route: "#f59e0b",
|
||||
in_progress: "#8b5cf6",
|
||||
@@ -34,6 +35,7 @@ const STATUS_COLORS = {
|
||||
rescheduled: "#f97316",
|
||||
};
|
||||
const STATUS_LABELS = {
|
||||
pending: "Pending",
|
||||
scheduled: "Scheduled",
|
||||
en_route: "En Route",
|
||||
in_progress: "In Progress",
|
||||
@@ -42,6 +44,7 @@ const STATUS_LABELS = {
|
||||
rescheduled: "Rescheduled",
|
||||
};
|
||||
const STATUS_ICONS = {
|
||||
pending: "fa-hourglass-half",
|
||||
scheduled: "fa-clock-o",
|
||||
en_route: "fa-truck",
|
||||
in_progress: "fa-wrench",
|
||||
@@ -51,12 +54,14 @@ const STATUS_ICONS = {
|
||||
};
|
||||
|
||||
// Date group keys
|
||||
const GROUP_PENDING = "pending";
|
||||
const GROUP_YESTERDAY = "yesterday";
|
||||
const GROUP_TODAY = "today";
|
||||
const GROUP_TOMORROW = "tomorrow";
|
||||
const GROUP_THIS_WEEK = "this_week";
|
||||
const GROUP_LATER = "later";
|
||||
const GROUP_LABELS = {
|
||||
[GROUP_PENDING]: "Pending",
|
||||
[GROUP_YESTERDAY]: "Yesterday",
|
||||
[GROUP_TODAY]: "Today",
|
||||
[GROUP_TOMORROW]: "Tomorrow",
|
||||
@@ -66,6 +71,7 @@ const GROUP_LABELS = {
|
||||
|
||||
// Pin colours by day group
|
||||
const DAY_COLORS = {
|
||||
[GROUP_PENDING]: "#f59e0b", // Amber
|
||||
[GROUP_YESTERDAY]: "#9ca3af", // Gray
|
||||
[GROUP_TODAY]: "#ef4444", // Red
|
||||
[GROUP_TOMORROW]: "#3b82f6", // Blue
|
||||
@@ -73,6 +79,7 @@ const DAY_COLORS = {
|
||||
[GROUP_LATER]: "#a855f7", // Purple
|
||||
};
|
||||
const DAY_ICONS = {
|
||||
[GROUP_PENDING]: "fa-hourglass-half",
|
||||
[GROUP_YESTERDAY]: "fa-history",
|
||||
[GROUP_TODAY]: "fa-exclamation-circle",
|
||||
[GROUP_TOMORROW]: "fa-arrow-right",
|
||||
@@ -137,9 +144,14 @@ function floatToTime12(flt) {
|
||||
return `${h12}:${String(m).padStart(2, "0")} ${ampm}`;
|
||||
}
|
||||
|
||||
/** Classify a "YYYY-MM-DD" string into one of our group keys */
|
||||
/** Classify a task into one of our group keys based on status and date */
|
||||
function classifyTask(task) {
|
||||
if (task.status === "pending") return GROUP_PENDING;
|
||||
return classifyDate(task.scheduled_date);
|
||||
}
|
||||
|
||||
function classifyDate(dateStr) {
|
||||
if (!dateStr) return GROUP_LATER;
|
||||
if (!dateStr) return GROUP_PENDING;
|
||||
const now = new Date();
|
||||
const todayStr = localDateStr(now);
|
||||
|
||||
@@ -151,7 +163,6 @@ function classifyDate(dateStr) {
|
||||
tmr.setDate(tmr.getDate() + 1);
|
||||
const tomorrowStr = localDateStr(tmr);
|
||||
|
||||
// End of this week (Sunday)
|
||||
const endOfWeek = new Date(now);
|
||||
endOfWeek.setDate(endOfWeek.getDate() + (7 - endOfWeek.getDay()));
|
||||
const endOfWeekStr = localDateStr(endOfWeek);
|
||||
@@ -160,7 +171,7 @@ function classifyDate(dateStr) {
|
||||
if (dateStr === todayStr) return GROUP_TODAY;
|
||||
if (dateStr === tomorrowStr) return GROUP_TOMORROW;
|
||||
if (dateStr <= endOfWeekStr && dateStr > tomorrowStr) return GROUP_THIS_WEEK;
|
||||
if (dateStr < yesterdayStr) return GROUP_YESTERDAY; // older lumped with yesterday
|
||||
if (dateStr < yesterdayStr) return GROUP_YESTERDAY;
|
||||
return GROUP_LATER;
|
||||
}
|
||||
|
||||
@@ -180,7 +191,7 @@ function groupTasks(tasksData, localInstanceId) {
|
||||
});
|
||||
|
||||
const groups = {};
|
||||
const order = [GROUP_YESTERDAY, GROUP_TODAY, GROUP_TOMORROW, GROUP_THIS_WEEK, GROUP_LATER];
|
||||
const order = [GROUP_PENDING, GROUP_YESTERDAY, GROUP_TODAY, GROUP_TOMORROW, GROUP_THIS_WEEK, GROUP_LATER];
|
||||
for (const key of order) {
|
||||
groups[key] = {
|
||||
key,
|
||||
@@ -195,7 +206,7 @@ function groupTasks(tasksData, localInstanceId) {
|
||||
let globalIdx = 0;
|
||||
for (const task of sorted) {
|
||||
globalIdx++;
|
||||
const g = classifyDate(task.scheduled_date);
|
||||
const g = classifyTask(task);
|
||||
task._scheduleNum = globalIdx;
|
||||
task._group = g;
|
||||
task._dayColor = DAY_COLORS[g] || "#6b7280"; // Pin colour by day
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
</button>
|
||||
<span class="border-start mx-1" style="height:20px;"/>
|
||||
<span class="text-muted fw-bold" style="font-size:11px;">Pins:</span>
|
||||
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#f59e0b;"/>Pending</span>
|
||||
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#ef4444;"/>Today</span>
|
||||
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#3b82f6;"/>Tomorrow</span>
|
||||
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#10b981;"/>This Week</span>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
domain="[('scheduled_date', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d')),
|
||||
('scheduled_date', '<=', (context_today() + datetime.timedelta(days=6-context_today().weekday())).strftime('%Y-%m-%d'))]"/>
|
||||
<separator/>
|
||||
<filter string="Pending" name="filter_pending" domain="[('status', '=', 'pending')]"/>
|
||||
<filter string="Scheduled" name="filter_scheduled" domain="[('status', '=', 'scheduled')]"/>
|
||||
<filter string="En Route" name="filter_en_route" domain="[('status', '=', 'en_route')]"/>
|
||||
<filter string="In Progress" name="filter_in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||
@@ -105,7 +106,7 @@
|
||||
class="btn-secondary o_fc_calculate_travel" icon="fa-car"
|
||||
invisible="x_fc_is_shadow"/>
|
||||
<field name="status" widget="statusbar"
|
||||
statusbar_visible="scheduled,en_route,in_progress,completed"/>
|
||||
statusbar_visible="pending,scheduled,en_route,in_progress,completed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<!-- Shadow task banner -->
|
||||
@@ -447,6 +448,15 @@
|
||||
<field name="context">{'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1}</field>
|
||||
</record>
|
||||
|
||||
<!-- Pending Tasks Action -->
|
||||
<record id="action_technician_tasks_pending" model="ir.actions.act_window">
|
||||
<field name="name">Pending Tasks</field>
|
||||
<field name="res_model">fusion.technician.task</field>
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="search_view_id" ref="view_technician_task_search"/>
|
||||
<field name="context">{'search_default_filter_pending': 1}</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- MENU ITEMS -->
|
||||
<!-- ================================================================== -->
|
||||
@@ -478,6 +488,12 @@
|
||||
action="action_technician_tasks"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_technician_tasks_pending"
|
||||
name="Pending Tasks"
|
||||
parent="menu_technician_management"
|
||||
action="action_technician_tasks_pending"
|
||||
sequence="13"/>
|
||||
|
||||
<menuitem id="menu_technician_tasks_today"
|
||||
name="Today's Tasks"
|
||||
parent="menu_technician_management"
|
||||
|
||||
17
fusion_poynt/__init__.py
Normal file
17
fusion_poynt/__init__.py
Normal 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})
|
||||
29
fusion_poynt/__manifest__.py
Normal file
29
fusion_poynt/__manifest__.py
Normal 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',
|
||||
}
|
||||
BIN
fusion_poynt/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_poynt/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_poynt/__pycache__/const.cpython-312.pyc
Normal file
BIN
fusion_poynt/__pycache__/const.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_poynt/__pycache__/utils.cpython-312.pyc
Normal file
BIN
fusion_poynt/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
84
fusion_poynt/const.py
Normal file
84
fusion_poynt/const.py
Normal 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',
|
||||
}
|
||||
3
fusion_poynt/controllers/__init__.py
Normal file
3
fusion_poynt/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
518
fusion_poynt/controllers/main.py
Normal file
518
fusion_poynt/controllers/main.py
Normal 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)
|
||||
12
fusion_poynt/data/payment_provider_data.xml
Normal file
12
fusion_poynt/data/payment_provider_data.xml
Normal 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>
|
||||
6
fusion_poynt/models/__init__.py
Normal file
6
fusion_poynt/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import payment_provider
|
||||
from . import payment_token
|
||||
from . import payment_transaction
|
||||
from . import poynt_terminal
|
||||
377
fusion_poynt/models/payment_provider.py
Normal file
377
fusion_poynt/models/payment_provider.py
Normal file
@@ -0,0 +1,377 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_poynt import const
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentProvider(models.Model):
|
||||
_inherit = 'payment.provider'
|
||||
|
||||
code = fields.Selection(
|
||||
selection_add=[('poynt', "Poynt")],
|
||||
ondelete={'poynt': 'set default'},
|
||||
)
|
||||
poynt_application_id = fields.Char(
|
||||
string="Application ID",
|
||||
help="The Poynt application ID (urn:aid:...) from your developer portal.",
|
||||
required_if_provider='poynt',
|
||||
copy=False,
|
||||
)
|
||||
poynt_private_key = fields.Text(
|
||||
string="Private Key (PEM)",
|
||||
help="The RSA private key in PEM format, downloaded from the Poynt developer portal. "
|
||||
"Used to sign JWT tokens for OAuth2 authentication.",
|
||||
required_if_provider='poynt',
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
poynt_business_id = fields.Char(
|
||||
string="Business ID",
|
||||
help="The merchant's Poynt business UUID.",
|
||||
required_if_provider='poynt',
|
||||
copy=False,
|
||||
)
|
||||
poynt_store_id = fields.Char(
|
||||
string="Store ID",
|
||||
help="The Poynt store UUID for this location.",
|
||||
copy=False,
|
||||
)
|
||||
poynt_webhook_secret = fields.Char(
|
||||
string="Webhook Secret",
|
||||
help="Secret key used to verify webhook notifications from Poynt.",
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
|
||||
# Cached access token fields (not visible in UI)
|
||||
_poynt_access_token = fields.Char(
|
||||
string="Access Token",
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
_poynt_token_expiry = fields.Integer(
|
||||
string="Token Expiry Timestamp",
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
|
||||
# === COMPUTE METHODS === #
|
||||
|
||||
def _compute_feature_support_fields(self):
|
||||
"""Override of `payment` to enable additional features."""
|
||||
super()._compute_feature_support_fields()
|
||||
self.filtered(lambda p: p.code == 'poynt').update({
|
||||
'support_manual_capture': 'full_only',
|
||||
'support_refund': 'partial',
|
||||
'support_tokenization': True,
|
||||
})
|
||||
|
||||
# === CRUD METHODS === #
|
||||
|
||||
def _get_default_payment_method_codes(self):
|
||||
"""Override of `payment` to return the default payment method codes."""
|
||||
self.ensure_one()
|
||||
if self.code != 'poynt':
|
||||
return super()._get_default_payment_method_codes()
|
||||
return const.DEFAULT_PAYMENT_METHOD_CODES
|
||||
|
||||
# === BUSINESS METHODS - AUTHENTICATION === #
|
||||
|
||||
def _poynt_get_access_token(self):
|
||||
"""Obtain an OAuth2 access token from Poynt using JWT bearer grant.
|
||||
|
||||
Caches the token and only refreshes when it's about to expire.
|
||||
|
||||
:return: The access token string.
|
||||
:rtype: str
|
||||
:raises ValidationError: If authentication fails.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
now = int(time.time())
|
||||
if self._poynt_access_token and self._poynt_token_expiry and now < self._poynt_token_expiry - 30:
|
||||
return self._poynt_access_token
|
||||
|
||||
jwt_assertion = poynt_utils.create_self_signed_jwt(
|
||||
self.poynt_application_id,
|
||||
self.poynt_private_key,
|
||||
)
|
||||
|
||||
token_url = f"{const.API_BASE_URL}{const.TOKEN_ENDPOINT}"
|
||||
if self.state == 'test':
|
||||
token_url = f"{const.API_BASE_URL_TEST}{const.TOKEN_ENDPOINT}"
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
token_url,
|
||||
data={
|
||||
'grantType': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion': jwt_assertion,
|
||||
},
|
||||
headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Api-Version': const.API_VERSION,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Poynt OAuth2 token request failed: %s", e)
|
||||
raise ValidationError(
|
||||
_("Failed to authenticate with Poynt. Please check your credentials. Error: %s", e)
|
||||
)
|
||||
|
||||
access_token = token_data.get('accessToken')
|
||||
expires_in = token_data.get('expiresIn', 900)
|
||||
|
||||
if not access_token:
|
||||
raise ValidationError(
|
||||
_("Poynt authentication returned no access token. "
|
||||
"Please verify your Application ID and Private Key.")
|
||||
)
|
||||
|
||||
self.sudo().write({
|
||||
'_poynt_access_token': access_token,
|
||||
'_poynt_token_expiry': now + expires_in,
|
||||
})
|
||||
|
||||
return access_token
|
||||
|
||||
# === BUSINESS METHODS - API REQUESTS === #
|
||||
|
||||
def _poynt_make_request(self, method, endpoint, payload=None, params=None,
|
||||
business_scoped=True, store_scoped=False):
|
||||
"""Make an authenticated API request to the Poynt REST API.
|
||||
|
||||
:param str method: HTTP method (GET, POST, PUT, PATCH, DELETE).
|
||||
:param str endpoint: The API endpoint path.
|
||||
:param dict payload: The JSON request body (optional).
|
||||
:param dict params: The query parameters (optional).
|
||||
:param bool business_scoped: Whether to scope the URL to the business.
|
||||
:param bool store_scoped: Whether to scope the URL to the store.
|
||||
:return: The parsed JSON response.
|
||||
:rtype: dict
|
||||
:raises ValidationError: If the API request fails.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
access_token = self._poynt_get_access_token()
|
||||
is_test = self.state == 'test'
|
||||
|
||||
business_id = self.poynt_business_id if business_scoped else None
|
||||
store_id = self.poynt_store_id if store_scoped and self.poynt_store_id else None
|
||||
|
||||
url = poynt_utils.build_api_url(
|
||||
endpoint,
|
||||
business_id=business_id,
|
||||
store_id=store_id,
|
||||
is_test=is_test,
|
||||
)
|
||||
|
||||
request_id = poynt_utils.generate_request_id()
|
||||
headers = poynt_utils.build_api_headers(access_token, request_id=request_id)
|
||||
|
||||
_logger.info(
|
||||
"Poynt API %s request to %s (request_id=%s)",
|
||||
method, url, request_id,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Poynt API request failed: %s", e)
|
||||
raise ValidationError(_("Communication with Poynt failed: %s", e))
|
||||
|
||||
if response.status_code == 401:
|
||||
self.sudo().write({
|
||||
'_poynt_access_token': False,
|
||||
'_poynt_token_expiry': 0,
|
||||
})
|
||||
raise ValidationError(
|
||||
_("Poynt authentication expired. Please retry.")
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {}
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
_logger.error("Poynt returned non-JSON response: %s", response.text[:500])
|
||||
raise ValidationError(_("Poynt returned an invalid response."))
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = result.get('message', result.get('developerMessage', 'Unknown error'))
|
||||
_logger.error(
|
||||
"Poynt API error %s: %s (request_id=%s)",
|
||||
response.status_code, error_msg, request_id,
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Poynt API error (%(code)s): %(msg)s",
|
||||
code=response.status_code, msg=error_msg)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# === BUSINESS METHODS - INLINE FORM === #
|
||||
|
||||
def _poynt_get_inline_form_values(self, amount, currency, partner_id, is_validation,
|
||||
payment_method_sudo=None, **kwargs):
|
||||
"""Return a serialized JSON of values needed to render the inline payment form.
|
||||
|
||||
:param float amount: The payment amount.
|
||||
:param recordset currency: The currency of the transaction.
|
||||
:param int partner_id: The partner ID.
|
||||
:param bool is_validation: Whether this is a validation (tokenization) operation.
|
||||
:param recordset payment_method_sudo: The sudoed payment method record.
|
||||
:return: The JSON-serialized inline form values.
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
partner = self.env['res.partner'].browse(partner_id).exists()
|
||||
minor_amount = poynt_utils.format_poynt_amount(amount, currency) if amount else 0
|
||||
|
||||
inline_form_values = {
|
||||
'business_id': self.poynt_business_id,
|
||||
'application_id': self.poynt_application_id,
|
||||
'currency_name': currency.name if currency else 'USD',
|
||||
'minor_amount': minor_amount,
|
||||
'capture_method': 'manual' if self.capture_manually else 'automatic',
|
||||
'is_test': self.state == 'test',
|
||||
'billing_details': {
|
||||
'name': partner.name or '',
|
||||
'email': partner.email or '',
|
||||
'phone': partner.phone or '',
|
||||
'address': {
|
||||
'line1': partner.street or '',
|
||||
'line2': partner.street2 or '',
|
||||
'city': partner.city or '',
|
||||
'state': partner.state_id.code or '',
|
||||
'country': partner.country_id.code or '',
|
||||
'postal_code': partner.zip or '',
|
||||
},
|
||||
},
|
||||
'is_tokenization_required': (
|
||||
self.allow_tokenization
|
||||
and self._is_tokenization_required(**kwargs)
|
||||
and payment_method_sudo
|
||||
and payment_method_sudo.support_tokenization
|
||||
),
|
||||
}
|
||||
return json.dumps(inline_form_values)
|
||||
|
||||
# === ACTION METHODS === #
|
||||
|
||||
def action_poynt_test_connection(self):
|
||||
"""Test the connection to Poynt by fetching business info.
|
||||
|
||||
:return: A notification action with the result.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
result = self._poynt_make_request('GET', '')
|
||||
business_name = result.get('legalName', result.get('doingBusinessAs', 'Unknown'))
|
||||
message = _(
|
||||
"Connection successful. Business: %(name)s",
|
||||
name=business_name,
|
||||
)
|
||||
notification_type = 'success'
|
||||
except (ValidationError, UserError) as e:
|
||||
message = _("Connection failed: %(error)s", error=str(e))
|
||||
notification_type = 'danger'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'message': message,
|
||||
'sticky': False,
|
||||
'type': notification_type,
|
||||
},
|
||||
}
|
||||
|
||||
def action_poynt_fetch_terminals(self):
|
||||
"""Fetch terminal devices from Poynt and create/update local records.
|
||||
|
||||
:return: A notification action with the result.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
store_id = self.poynt_store_id
|
||||
if store_id:
|
||||
endpoint = f'stores/{store_id}/storeDevices'
|
||||
else:
|
||||
endpoint = 'storeDevices'
|
||||
|
||||
result = self._poynt_make_request('GET', endpoint)
|
||||
devices = result if isinstance(result, list) else result.get('storeDevices', [])
|
||||
|
||||
terminal_model = self.env['poynt.terminal']
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for device in devices:
|
||||
device_id = device.get('deviceId', '')
|
||||
existing = terminal_model.search([
|
||||
('device_id', '=', device_id),
|
||||
('provider_id', '=', self.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'name': device.get('name', device_id),
|
||||
'device_id': device_id,
|
||||
'serial_number': device.get('serialNumber', ''),
|
||||
'provider_id': self.id,
|
||||
'status': 'online' if device.get('status') == 'ACTIVATED' else 'offline',
|
||||
'store_id_poynt': device.get('storeId', ''),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
updated += 1
|
||||
else:
|
||||
terminal_model.create(vals)
|
||||
created += 1
|
||||
|
||||
message = _(
|
||||
"Terminals synced: %(created)s created, %(updated)s updated.",
|
||||
created=created, updated=updated,
|
||||
)
|
||||
notification_type = 'success'
|
||||
except (ValidationError, UserError) as e:
|
||||
message = _("Failed to fetch terminals: %(error)s", error=str(e))
|
||||
notification_type = 'danger'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'message': message,
|
||||
'sticky': False,
|
||||
'type': notification_type,
|
||||
},
|
||||
}
|
||||
55
fusion_poynt/models/payment_token.py
Normal file
55
fusion_poynt/models/payment_token.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentToken(models.Model):
|
||||
_inherit = 'payment.token'
|
||||
|
||||
poynt_card_id = fields.Char(
|
||||
string="Poynt Card ID",
|
||||
help="The unique card identifier stored on the Poynt platform.",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
def _poynt_validate_stored_card(self):
|
||||
"""Validate that the stored card is still usable on Poynt.
|
||||
|
||||
Fetches the card details from Poynt to confirm the card ID is valid
|
||||
and the card is still active.
|
||||
|
||||
:return: True if the card is valid.
|
||||
:rtype: bool
|
||||
:raises ValidationError: If the card cannot be validated.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.poynt_card_id:
|
||||
raise ValidationError(
|
||||
_("No Poynt card ID found on this payment token.")
|
||||
)
|
||||
|
||||
try:
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'GET',
|
||||
f'cards/{self.poynt_card_id}',
|
||||
)
|
||||
status = result.get('status', '')
|
||||
if status != 'ACTIVE':
|
||||
raise ValidationError(
|
||||
_("The stored card is no longer active on Poynt (status: %(status)s).",
|
||||
status=status)
|
||||
)
|
||||
return True
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to validate Poynt card %s: %s", self.poynt_card_id, e)
|
||||
raise ValidationError(
|
||||
_("Unable to validate the stored card with Poynt.")
|
||||
)
|
||||
386
fusion_poynt/models/payment_transaction.py
Normal file
386
fusion_poynt/models/payment_transaction.py
Normal file
@@ -0,0 +1,386 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.urls import urljoin as url_join
|
||||
|
||||
from odoo.addons.fusion_poynt import const
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
from odoo.addons.fusion_poynt.controllers.main import PoyntController
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
poynt_order_id = fields.Char(
|
||||
string="Poynt Order ID",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
poynt_transaction_id = fields.Char(
|
||||
string="Poynt Transaction ID",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# === BUSINESS METHODS - PAYMENT FLOW === #
|
||||
|
||||
def _get_specific_processing_values(self, processing_values):
|
||||
"""Override of payment to return Poynt-specific processing values.
|
||||
|
||||
For direct (online) payments we create a Poynt order upfront and return
|
||||
identifiers plus the return URL so the frontend JS can complete the flow.
|
||||
"""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._get_specific_processing_values(processing_values)
|
||||
|
||||
if self.operation == 'online_token':
|
||||
return {}
|
||||
|
||||
poynt_data = self._poynt_create_order_and_authorize()
|
||||
|
||||
base_url = self.provider_id.get_base_url()
|
||||
return_url = url_join(
|
||||
base_url,
|
||||
f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}',
|
||||
)
|
||||
|
||||
return {
|
||||
'poynt_order_id': poynt_data.get('order_id', ''),
|
||||
'poynt_transaction_id': poynt_data.get('transaction_id', ''),
|
||||
'return_url': return_url,
|
||||
'business_id': self.provider_id.poynt_business_id,
|
||||
'is_test': self.provider_id.state == 'test',
|
||||
}
|
||||
|
||||
def _send_payment_request(self):
|
||||
"""Override of `payment` to send a payment request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_payment_request()
|
||||
|
||||
if self.operation in ('online_token', 'offline'):
|
||||
return self._poynt_process_token_payment()
|
||||
|
||||
poynt_data = self._poynt_create_order_and_authorize()
|
||||
if poynt_data:
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_order_id': poynt_data.get('order_id'),
|
||||
'poynt_transaction_id': poynt_data.get('transaction_id'),
|
||||
'poynt_status': poynt_data.get('status', 'AUTHORIZED'),
|
||||
'funding_source': poynt_data.get('funding_source', {}),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
|
||||
def _poynt_create_order_and_authorize(self):
|
||||
"""Create a Poynt order and authorize the transaction.
|
||||
|
||||
:return: Dict with order_id, transaction_id, status, and funding_source.
|
||||
:rtype: dict
|
||||
"""
|
||||
try:
|
||||
order_payload = poynt_utils.build_order_payload(
|
||||
self.reference, self.amount, self.currency_id,
|
||||
)
|
||||
order_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'orders', payload=order_payload,
|
||||
)
|
||||
order_id = order_result.get('id', '')
|
||||
self.poynt_order_id = order_id
|
||||
|
||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
||||
txn_payload = poynt_utils.build_transaction_payload(
|
||||
action=action,
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
order_id=order_id,
|
||||
reference=self.reference,
|
||||
)
|
||||
txn_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=txn_payload,
|
||||
)
|
||||
|
||||
transaction_id = txn_result.get('id', '')
|
||||
self.poynt_transaction_id = transaction_id
|
||||
self.provider_reference = transaction_id
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'transaction_id': transaction_id,
|
||||
'status': txn_result.get('status', ''),
|
||||
'funding_source': txn_result.get('fundingSource', {}),
|
||||
}
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
return {}
|
||||
|
||||
def _poynt_process_token_payment(self):
|
||||
"""Process a payment using a stored token (card on file).
|
||||
|
||||
For token-based payments we send a SALE or AUTHORIZE using the
|
||||
stored card ID from the payment token.
|
||||
"""
|
||||
try:
|
||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
||||
|
||||
funding_source = {
|
||||
'type': 'CREDIT_DEBIT',
|
||||
'card': {
|
||||
'cardId': self.token_id.poynt_card_id,
|
||||
},
|
||||
}
|
||||
|
||||
order_payload = poynt_utils.build_order_payload(
|
||||
self.reference, self.amount, self.currency_id,
|
||||
)
|
||||
order_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'orders', payload=order_payload,
|
||||
)
|
||||
order_id = order_result.get('id', '')
|
||||
self.poynt_order_id = order_id
|
||||
|
||||
txn_payload = poynt_utils.build_transaction_payload(
|
||||
action=action,
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
order_id=order_id,
|
||||
reference=self.reference,
|
||||
funding_source=funding_source,
|
||||
)
|
||||
txn_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=txn_payload,
|
||||
)
|
||||
|
||||
transaction_id = txn_result.get('id', '')
|
||||
self.poynt_transaction_id = transaction_id
|
||||
self.provider_reference = transaction_id
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_order_id': order_id,
|
||||
'poynt_transaction_id': transaction_id,
|
||||
'poynt_status': txn_result.get('status', ''),
|
||||
'funding_source': txn_result.get('fundingSource', {}),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_refund_request(self):
|
||||
"""Override of `payment` to send a refund request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_refund_request()
|
||||
|
||||
source_tx = self.source_transaction_id
|
||||
refund_amount = abs(self.amount)
|
||||
minor_amount = poynt_utils.format_poynt_amount(refund_amount, self.currency_id)
|
||||
|
||||
try:
|
||||
refund_payload = {
|
||||
'action': 'REFUND',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'amounts': {
|
||||
'transactionAmount': minor_amount,
|
||||
'orderAmount': minor_amount,
|
||||
'currency': self.currency_id.name,
|
||||
},
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
'notes': f'Refund for {source_tx.reference}',
|
||||
}
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=refund_payload,
|
||||
)
|
||||
|
||||
self.provider_reference = result.get('id', '')
|
||||
self.poynt_transaction_id = result.get('id', '')
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_transaction_id': result.get('id'),
|
||||
'poynt_status': result.get('status', 'REFUNDED'),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_capture_request(self):
|
||||
"""Override of `payment` to send a capture request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_capture_request()
|
||||
|
||||
source_tx = self.source_transaction_id
|
||||
minor_amount = poynt_utils.format_poynt_amount(self.amount, self.currency_id)
|
||||
|
||||
try:
|
||||
capture_payload = {
|
||||
'action': 'CAPTURE',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'amounts': {
|
||||
'transactionAmount': minor_amount,
|
||||
'orderAmount': minor_amount,
|
||||
'currency': self.currency_id.name,
|
||||
},
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
}
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=capture_payload,
|
||||
)
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_transaction_id': result.get('id'),
|
||||
'poynt_status': result.get('status', 'CAPTURED'),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_void_request(self):
|
||||
"""Override of `payment` to send a void request to Poynt."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_void_request()
|
||||
|
||||
source_tx = self.source_transaction_id
|
||||
|
||||
try:
|
||||
void_payload = {
|
||||
'action': 'VOID',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
}
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=void_payload,
|
||||
)
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_transaction_id': result.get('id'),
|
||||
'poynt_status': result.get('status', 'VOIDED'),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
# === BUSINESS METHODS - NOTIFICATION PROCESSING === #
|
||||
|
||||
@api.model
|
||||
def _search_by_reference(self, provider_code, payment_data):
|
||||
"""Override of payment to find the transaction based on Poynt data."""
|
||||
if provider_code != 'poynt':
|
||||
return super()._search_by_reference(provider_code, payment_data)
|
||||
|
||||
reference = payment_data.get('reference')
|
||||
if reference:
|
||||
tx = self.search([
|
||||
('reference', '=', reference),
|
||||
('provider_code', '=', 'poynt'),
|
||||
])
|
||||
else:
|
||||
poynt_txn_id = payment_data.get('poynt_transaction_id')
|
||||
if poynt_txn_id:
|
||||
tx = self.search([
|
||||
('poynt_transaction_id', '=', poynt_txn_id),
|
||||
('provider_code', '=', 'poynt'),
|
||||
])
|
||||
else:
|
||||
_logger.warning("Received Poynt data with no reference or transaction ID")
|
||||
tx = self
|
||||
|
||||
if not tx:
|
||||
_logger.warning(
|
||||
"No transaction found matching Poynt reference %s", reference,
|
||||
)
|
||||
|
||||
return tx
|
||||
|
||||
def _apply_updates(self, payment_data):
|
||||
"""Override of `payment` to update the transaction based on Poynt data."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._apply_updates(payment_data)
|
||||
|
||||
poynt_txn_id = payment_data.get('poynt_transaction_id')
|
||||
if poynt_txn_id:
|
||||
self.provider_reference = poynt_txn_id
|
||||
self.poynt_transaction_id = poynt_txn_id
|
||||
|
||||
poynt_order_id = payment_data.get('poynt_order_id')
|
||||
if poynt_order_id:
|
||||
self.poynt_order_id = poynt_order_id
|
||||
|
||||
funding_source = payment_data.get('funding_source', {})
|
||||
if funding_source:
|
||||
card_details = poynt_utils.extract_card_details(funding_source)
|
||||
if card_details.get('brand'):
|
||||
payment_method = self.env['payment.method']._get_from_code(
|
||||
card_details['brand'],
|
||||
mapping=const.CARD_BRAND_MAPPING,
|
||||
)
|
||||
if payment_method:
|
||||
self.payment_method_id = payment_method
|
||||
|
||||
status = payment_data.get('poynt_status', '')
|
||||
if not status:
|
||||
self._set_error(_("Received data with missing transaction status."))
|
||||
return
|
||||
|
||||
odoo_state = poynt_utils.get_poynt_status(status)
|
||||
|
||||
if odoo_state == 'authorized':
|
||||
self._set_authorized()
|
||||
elif odoo_state == 'done':
|
||||
self._set_done()
|
||||
if self.operation == 'refund':
|
||||
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
|
||||
elif odoo_state == 'cancel':
|
||||
self._set_canceled()
|
||||
elif odoo_state == 'refund':
|
||||
self._set_done()
|
||||
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
|
||||
elif odoo_state == 'error':
|
||||
error_msg = payment_data.get('error_message', _("Payment was declined by Poynt."))
|
||||
self._set_error(error_msg)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Received unknown Poynt status (%s) for transaction %s.",
|
||||
status, self.reference,
|
||||
)
|
||||
self._set_error(
|
||||
_("Received data with unrecognized status: %s.", status)
|
||||
)
|
||||
|
||||
def _extract_token_values(self, payment_data):
|
||||
"""Override of `payment` to return token data based on Poynt data."""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._extract_token_values(payment_data)
|
||||
|
||||
funding_source = payment_data.get('funding_source', {})
|
||||
card_details = poynt_utils.extract_card_details(funding_source)
|
||||
|
||||
if not card_details:
|
||||
_logger.warning(
|
||||
"Tokenization requested but no card data in payment response."
|
||||
)
|
||||
return {}
|
||||
|
||||
return {
|
||||
'payment_details': card_details.get('last4', ''),
|
||||
'poynt_card_id': card_details.get('card_id', ''),
|
||||
}
|
||||
202
fusion_poynt/models/poynt_terminal.py
Normal file
202
fusion_poynt/models/poynt_terminal.py
Normal file
@@ -0,0 +1,202 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PoyntTerminal(models.Model):
|
||||
_name = 'poynt.terminal'
|
||||
_description = 'Poynt Terminal Device'
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string="Terminal Name",
|
||||
required=True,
|
||||
)
|
||||
device_id = fields.Char(
|
||||
string="Device ID",
|
||||
help="The Poynt device identifier (urn:tid:...).",
|
||||
required=True,
|
||||
copy=False,
|
||||
)
|
||||
serial_number = fields.Char(
|
||||
string="Serial Number",
|
||||
copy=False,
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'payment.provider',
|
||||
string="Payment Provider",
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
domain="[('code', '=', 'poynt')]",
|
||||
)
|
||||
store_id_poynt = fields.Char(
|
||||
string="Poynt Store ID",
|
||||
help="The Poynt store UUID this terminal belongs to.",
|
||||
)
|
||||
status = fields.Selection(
|
||||
selection=[
|
||||
('online', "Online"),
|
||||
('offline', "Offline"),
|
||||
('unknown', "Unknown"),
|
||||
],
|
||||
string="Status",
|
||||
default='unknown',
|
||||
readonly=True,
|
||||
)
|
||||
last_seen = fields.Datetime(
|
||||
string="Last Seen",
|
||||
readonly=True,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
default=True,
|
||||
)
|
||||
|
||||
_unique_device_provider = models.Constraint(
|
||||
'UNIQUE(device_id, provider_id)',
|
||||
'A terminal with this device ID already exists for this provider.',
|
||||
)
|
||||
|
||||
# === BUSINESS METHODS === #
|
||||
|
||||
def action_refresh_status(self):
|
||||
"""Refresh the terminal status from Poynt Cloud."""
|
||||
for terminal in self:
|
||||
try:
|
||||
store_id = terminal.store_id_poynt or terminal.provider_id.poynt_store_id
|
||||
if store_id:
|
||||
endpoint = f'stores/{store_id}/storeDevices/{terminal.device_id}'
|
||||
else:
|
||||
endpoint = f'storeDevices/{terminal.device_id}'
|
||||
|
||||
result = terminal.provider_id._poynt_make_request('GET', endpoint)
|
||||
poynt_status = result.get('status', 'UNKNOWN')
|
||||
|
||||
if poynt_status == 'ACTIVATED':
|
||||
terminal.status = 'online'
|
||||
elif poynt_status in ('DEACTIVATED', 'INACTIVE'):
|
||||
terminal.status = 'offline'
|
||||
else:
|
||||
terminal.status = 'unknown'
|
||||
|
||||
terminal.last_seen = fields.Datetime.now()
|
||||
except (ValidationError, UserError) as e:
|
||||
_logger.warning(
|
||||
"Failed to refresh status for terminal %s: %s",
|
||||
terminal.device_id, e,
|
||||
)
|
||||
terminal.status = 'unknown'
|
||||
|
||||
def action_send_payment_to_terminal(self, amount, currency, reference, order_id=None):
|
||||
"""Push a payment request to the physical Poynt terminal.
|
||||
|
||||
This sends a cloud message to the terminal device instructing it
|
||||
to start a payment collection for the given amount.
|
||||
|
||||
:param float amount: The payment amount in major currency units.
|
||||
:param recordset currency: The currency record.
|
||||
:param str reference: The Odoo payment reference.
|
||||
:param str order_id: Optional Poynt order UUID to link.
|
||||
:return: The Poynt cloud message response.
|
||||
:rtype: dict
|
||||
:raises UserError: If the terminal is offline.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if self.status == 'offline':
|
||||
raise UserError(
|
||||
_("Terminal '%(name)s' is offline. Please check the device.",
|
||||
name=self.name)
|
||||
)
|
||||
|
||||
minor_amount = poynt_utils.format_poynt_amount(amount, currency)
|
||||
|
||||
payment_request = {
|
||||
'amount': minor_amount,
|
||||
'currency': currency.name,
|
||||
'referenceId': reference,
|
||||
'callbackUrl': self._get_terminal_callback_url(),
|
||||
'skipReceiptScreen': False,
|
||||
'debit': True,
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payment_request['orderId'] = order_id
|
||||
|
||||
try:
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST',
|
||||
f'cloudMessages',
|
||||
payload={
|
||||
'deviceId': self.device_id,
|
||||
'ttl': 300,
|
||||
'serialNum': self.serial_number or '',
|
||||
'data': {
|
||||
'action': 'sale',
|
||||
'purchaseAmount': minor_amount,
|
||||
'tipAmount': 0,
|
||||
'currency': currency.name,
|
||||
'referenceId': reference,
|
||||
'callbackUrl': self._get_terminal_callback_url(),
|
||||
},
|
||||
},
|
||||
)
|
||||
_logger.info(
|
||||
"Payment request sent to terminal %s for %s %s (ref: %s)",
|
||||
self.device_id, amount, currency.name, reference,
|
||||
)
|
||||
return result
|
||||
except (ValidationError, UserError) as e:
|
||||
_logger.error(
|
||||
"Failed to send payment to terminal %s: %s",
|
||||
self.device_id, e,
|
||||
)
|
||||
raise
|
||||
|
||||
def _get_terminal_callback_url(self):
|
||||
"""Build the callback URL for terminal payment completion.
|
||||
|
||||
:return: The full callback URL.
|
||||
:rtype: str
|
||||
"""
|
||||
base_url = self.provider_id.get_base_url()
|
||||
return f"{base_url}/payment/poynt/terminal/callback"
|
||||
|
||||
def action_check_terminal_payment_status(self, reference):
|
||||
"""Poll for the status of a terminal payment.
|
||||
|
||||
:param str reference: The Odoo transaction reference.
|
||||
:return: Dict with status and transaction data if completed.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
txn_result = self.provider_id._poynt_make_request(
|
||||
'GET',
|
||||
'transactions',
|
||||
params={
|
||||
'notes': reference,
|
||||
'limit': 1,
|
||||
},
|
||||
)
|
||||
|
||||
transactions = txn_result.get('transactions', [])
|
||||
if not transactions:
|
||||
return {'status': 'pending', 'message': 'Waiting for terminal response...'}
|
||||
|
||||
txn = transactions[0]
|
||||
return {
|
||||
'status': txn.get('status', 'UNKNOWN'),
|
||||
'transaction_id': txn.get('id', ''),
|
||||
'funding_source': txn.get('fundingSource', {}),
|
||||
'amounts': txn.get('amounts', {}),
|
||||
}
|
||||
except (ValidationError, UserError):
|
||||
return {'status': 'error', 'message': 'Failed to check payment status.'}
|
||||
3
fusion_poynt/security/ir.model.access.csv
Normal file
3
fusion_poynt/security/ir.model.access.csv
Normal 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
|
||||
|
BIN
fusion_poynt/static/description/icon.png
Normal file
BIN
fusion_poynt/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
374
fusion_poynt/static/src/interactions/payment_form.js
Normal file
374
fusion_poynt/static/src/interactions/payment_form.js
Normal 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,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
136
fusion_poynt/static/src/interactions/terminal_payment.js
Normal file
136
fusion_poynt/static/src/interactions/terminal_payment.js
Normal 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
251
fusion_poynt/utils.py
Normal 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
|
||||
84
fusion_poynt/views/payment_poynt_templates.xml
Normal file
84
fusion_poynt/views/payment_poynt_templates.xml
Normal 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>
|
||||
50
fusion_poynt/views/payment_provider_views.xml
Normal file
50
fusion_poynt/views/payment_provider_views.xml
Normal 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----- ... -----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>
|
||||
110
fusion_poynt/views/poynt_terminal_views.xml
Normal file
110
fusion_poynt/views/poynt_terminal_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user