# 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 """ if not request_id: request_id = generate_request_id() headers = { 'Content-Type': 'application/json', 'api-version': const.API_VERSION, 'Accept': 'application/json', 'Authorization': f'Bearer {access_token}', 'Poynt-Request-Id': request_id, } return headers def clean_application_id(raw_app_id): """Extract the urn:aid:... portion from a raw application ID string. Poynt developer portal sometimes displays the app UUID and URN together (e.g. 'a73a2957-...=urn:aid:fb0ba879-...'). The JWT needs only the URN. :param str raw_app_id: The raw application ID string. :return: The cleaned application ID (urn:aid:...). :rtype: str """ if not raw_app_id: return raw_app_id raw_app_id = raw_app_id.strip() if 'urn:aid:' in raw_app_id: idx = raw_app_id.index('urn:aid:') return raw_app_id[idx:] return raw_app_id 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()) app_id = clean_application_id(application_id) now = int(time.time()) payload = { 'iss': app_id, 'sub': app_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, business_id='', store_id='', 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 str business_id: The Poynt business UUID. :param str store_id: The Poynt store UUID. :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', }] context = { 'source': 'WEB', 'sourceApp': 'odoo.fusion_poynt', } if business_id: context['businessId'] = business_id if store_id: context['storeId'] = store_id return { 'items': items, 'amounts': { 'subTotal': minor_amount, 'discountTotal': 0, 'feeTotal': 0, 'taxTotal': 0, 'netTotal': minor_amount, 'currency': currency.name, }, 'context': context, '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