- 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>
252 lines
7.9 KiB
Python
252 lines
7.9 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
|
|
"""
|
|
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
|