Files
Odoo-Modules/fusion_poynt/utils.py
gsinghpal 14fe9ab716 feat: hide authorizer for rental orders, auto-set sale type
Rental orders no longer show the "Authorizer Required?" question or
the Authorizer field. The sale type is automatically set to 'Rentals'
when creating or confirming a rental order. Validation logic also
skips authorizer checks for rental sale type.

Made-with: Cursor
2026-02-25 23:33:23 -05:00

334 lines
10 KiB
Python

# 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, business_id='', store_id='',
):
"""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).
:param str business_id: The Poynt business UUID (optional).
:param str store_id: The Poynt store UUID (optional).
:return: The Poynt-formatted transaction payload.
:rtype: dict
"""
minor_amount = format_poynt_amount(amount, currency)
context = {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
}
if business_id:
context['businessId'] = business_id
if store_id:
context['storeId'] = store_id
payload = {
'action': action,
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'tipAmount': 0,
'cashbackAmount': 0,
'currency': currency.name,
},
'context': context,
'notes': reference,
}
if order_id:
payload['references'] = [{
'id': order_id,
'type': 'POYNT_ORDER',
}]
if funding_source:
payload['fundingSource'] = funding_source
return payload
def build_token_charge_payload(
action, amount, currency, payment_jwt,
business_id='', store_id='', reference='',
):
"""Build a payload for POST /cards/tokenize/charge.
:param str action: SALE or AUTHORIZE.
:param float amount: Amount in major currency units.
:param recordset currency: Currency record.
:param str payment_jwt: The payment token JWT from /cards/tokenize.
:param str business_id: Poynt business UUID.
:param str store_id: Poynt store UUID.
:param str reference: Optional reference note.
:return: The charge payload dict.
:rtype: dict
"""
minor_amount = format_poynt_amount(amount, currency)
context = {}
if business_id:
context['businessId'] = business_id
if store_id:
context['storeId'] = store_id
payload = {
'action': action,
'context': context,
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'currency': currency.name,
},
'fundingSource': {
'cardToken': payment_jwt,
},
}
if reference:
payload['notes'] = reference
return payload