diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 3cc8f5b..e885548 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -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() diff --git a/fusion_claims/static/src/js/fusion_task_map_view.js b/fusion_claims/static/src/js/fusion_task_map_view.js index c131766..8ee5923 100644 --- a/fusion_claims/static/src/js/fusion_task_map_view.js +++ b/fusion_claims/static/src/js/fusion_task_map_view.js @@ -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 diff --git a/fusion_claims/static/src/xml/fusion_task_map_view.xml b/fusion_claims/static/src/xml/fusion_task_map_view.xml index 8c28297..a420046 100644 --- a/fusion_claims/static/src/xml/fusion_task_map_view.xml +++ b/fusion_claims/static/src/xml/fusion_task_map_view.xml @@ -165,6 +165,7 @@ Pins: + Pending Today Tomorrow This Week diff --git a/fusion_claims/views/technician_task_views.xml b/fusion_claims/views/technician_task_views.xml index 69aabc4..47036ad 100644 --- a/fusion_claims/views/technician_task_views.xml +++ b/fusion_claims/views/technician_task_views.xml @@ -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'))]"/> + @@ -105,7 +106,7 @@ class="btn-secondary o_fc_calculate_travel" icon="fa-car" invisible="x_fc_is_shadow"/> + statusbar_visible="pending,scheduled,en_route,in_progress,completed"/> @@ -447,6 +448,15 @@ {'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1} + + + Pending Tasks + fusion.technician.task + list,kanban,form + + {'search_default_filter_pending': 1} + + @@ -478,6 +488,12 @@ action="action_technician_tasks" sequence="20"/> + + + + + + Poynt + poynt + + True + disabled + + + diff --git a/fusion_poynt/models/__init__.py b/fusion_poynt/models/__init__.py new file mode 100644 index 0000000..105dc07 --- /dev/null +++ b/fusion_poynt/models/__init__.py @@ -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 diff --git a/fusion_poynt/models/payment_provider.py b/fusion_poynt/models/payment_provider.py new file mode 100644 index 0000000..1f9b88f --- /dev/null +++ b/fusion_poynt/models/payment_provider.py @@ -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, + }, + } diff --git a/fusion_poynt/models/payment_token.py b/fusion_poynt/models/payment_token.py new file mode 100644 index 0000000..f863769 --- /dev/null +++ b/fusion_poynt/models/payment_token.py @@ -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.") + ) diff --git a/fusion_poynt/models/payment_transaction.py b/fusion_poynt/models/payment_transaction.py new file mode 100644 index 0000000..4f18e67 --- /dev/null +++ b/fusion_poynt/models/payment_transaction.py @@ -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', ''), + } diff --git a/fusion_poynt/models/poynt_terminal.py b/fusion_poynt/models/poynt_terminal.py new file mode 100644 index 0000000..35bc2b1 --- /dev/null +++ b/fusion_poynt/models/poynt_terminal.py @@ -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.'} diff --git a/fusion_poynt/security/ir.model.access.csv b/fusion_poynt/security/ir.model.access.csv new file mode 100644 index 0000000..0f7529a --- /dev/null +++ b/fusion_poynt/security/ir.model.access.csv @@ -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 diff --git a/fusion_poynt/static/description/icon.png b/fusion_poynt/static/description/icon.png new file mode 100644 index 0000000..73b38d5 Binary files /dev/null and b/fusion_poynt/static/description/icon.png differ diff --git a/fusion_poynt/static/src/interactions/payment_form.js b/fusion_poynt/static/src/interactions/payment_form.js new file mode 100644 index 0000000..d4d23d6 --- /dev/null +++ b/fusion_poynt/static/src/interactions/payment_form.js @@ -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 = ` +
+
+ Loading... +
+
${_t("Waiting for terminal payment...")}
+

+ ${_t("Please complete the payment on the terminal device.")} +

+

+ ${_t("Checking status...")} +

+
+ `; + } + + 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, + ); + } + }, + +}); diff --git a/fusion_poynt/static/src/interactions/terminal_payment.js b/fusion_poynt/static/src/interactions/terminal_payment.js new file mode 100644 index 0000000..e0d43a8 --- /dev/null +++ b/fusion_poynt/static/src/interactions/terminal_payment.js @@ -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); + } +} diff --git a/fusion_poynt/utils.py b/fusion_poynt/utils.py new file mode 100644 index 0000000..4d57f65 --- /dev/null +++ b/fusion_poynt/utils.py @@ -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 diff --git a/fusion_poynt/views/payment_poynt_templates.xml b/fusion_poynt/views/payment_poynt_templates.xml new file mode 100644 index 0000000..02ada33 --- /dev/null +++ b/fusion_poynt/views/payment_poynt_templates.xml @@ -0,0 +1,84 @@ + + + + + + + diff --git a/fusion_poynt/views/payment_provider_views.xml b/fusion_poynt/views/payment_provider_views.xml new file mode 100644 index 0000000..dc10bf4 --- /dev/null +++ b/fusion_poynt/views/payment_provider_views.xml @@ -0,0 +1,50 @@ + + + + + Poynt Provider Form + payment.provider + + + + + + + + + + + + +