from __future__ import annotations import base64 import json import re import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from datetime import UTC, date, datetime, time from decimal import Decimal, InvalidOperation from typing import Any, Literal PAYPAL_LIVE_BASE_URL = "https://api-m.paypal.com" PAYPAL_SANDBOX_BASE_URL = "https://api-m.sandbox.paypal.com" PAYPAL_REPORTING_MAX_DAYS = 31 PAYPAL_TRANSACTION_SEARCH_SCOPE = "https://uri.paypal.com/services/reporting/search/read" @dataclass(frozen=True) class PayPalConfig: client_id: str client_secret: str environment: Literal["live", "sandbox"] = "live" base_url: str | None = None @dataclass(frozen=True) class PayPalFetchResult: start_date: date end_date: date transactions: list[dict[str, Any]] observations: list[dict[str, Any]] raw_response_count: int class PayPalAPIError(RuntimeError): pass def inspect_paypal_credentials(config: PayPalConfig) -> dict[str, Any]: return _token_payload_to_auth_check(config, fetch_access_token_payload(config)) def _token_payload_to_auth_check(config: PayPalConfig, payload: dict[str, Any]) -> dict[str, Any]: scope = payload.get("scope") scopes = [item for item in re.split(r"[\s,]+", scope.strip()) if item] if isinstance(scope, str) else [] return { "environment": config.environment, "base_url": _base_url(config), "app_id": _string_or_none(payload.get("app_id")), "token_type": _string_or_none(payload.get("token_type")), "expires_in": _int_or_none(payload.get("expires_in")), "scope_text": scope if isinstance(scope, str) else None, "scopes": scopes, "required_scope": PAYPAL_TRANSACTION_SEARCH_SCOPE, "has_transaction_search": PAYPAL_TRANSACTION_SEARCH_SCOPE in scopes, } def fetch_paypal_transactions( config: PayPalConfig, *, start_date: date, end_date: date, household_id: str | None = None, source_account_id: str | None = None, owner_user_id: str | None = None, transaction_currency: str | None = None, page_size: int = 100, max_pages: int = 10, ) -> PayPalFetchResult: _validate_date_range(start_date, end_date) token = fetch_access_token(config) details = list_transaction_details( config, token=token, start_date=start_date, end_date=end_date, transaction_currency=transaction_currency, page_size=page_size, max_pages=max_pages, ) observations = [ paypal_transaction_to_observation( transaction, household_id=household_id, source_account_id=source_account_id, owner_user_id=owner_user_id, ) for transaction in details if _transaction_amount(transaction) is not None ] return PayPalFetchResult( start_date=start_date, end_date=end_date, transactions=[paypal_transaction_to_preview(transaction) for transaction in details], observations=observations, raw_response_count=len(details), ) def fetch_access_token(config: PayPalConfig) -> str: payload = fetch_access_token_payload(config) access_token = payload.get("access_token") if not isinstance(access_token, str) or not access_token: raise PayPalAPIError("PayPal token response did not include an access token.") scope = payload.get("scope") if isinstance(scope, str) and PAYPAL_TRANSACTION_SEARCH_SCOPE not in scope.split(): raise PayPalAPIError( "PayPal credentials do not include Transaction Search permission. " f"Enable Transaction Search on the PayPal REST app and verify the OAuth token includes " f"{PAYPAL_TRANSACTION_SEARCH_SCOPE}." ) return access_token def fetch_access_token_payload(config: PayPalConfig) -> dict[str, Any]: credentials = f"{config.client_id}:{config.client_secret}".encode() body = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode("ascii") request = urllib.request.Request( f"{_base_url(config)}/v1/oauth2/token", data=body, method="POST", headers={ "Authorization": f"Basic {base64.b64encode(credentials).decode('ascii')}", "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }, ) return _request_json(request) def list_transaction_details( config: PayPalConfig, *, token: str, start_date: date, end_date: date, transaction_currency: str | None = None, page_size: int = 100, max_pages: int = 10, ) -> list[dict[str, Any]]: page_size = max(1, min(page_size, 500)) max_pages = max(1, max_pages) transactions: list[dict[str, Any]] = [] page = 1 total_pages: int | None = None while page <= max_pages and (total_pages is None or page <= total_pages): params = { "start_date": _paypal_datetime(start_date, end_of_day=False), "end_date": _paypal_datetime(end_date, end_of_day=True), "fields": "all", "page_size": str(page_size), "page": str(page), } if transaction_currency: params["transaction_currency"] = transaction_currency.upper() url = f"{_base_url(config)}/v1/reporting/transactions?{urllib.parse.urlencode(params)}" request = urllib.request.Request( url, method="GET", headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", }, ) payload = _request_json(request) page_transactions = payload.get("transaction_details") or [] if not isinstance(page_transactions, list): raise PayPalAPIError("PayPal transaction response has an unexpected shape.") transactions.extend(item for item in page_transactions if isinstance(item, dict)) total_pages = _int_or_none(payload.get("total_pages")) if total_pages is None and len(page_transactions) < page_size: break page += 1 return transactions def paypal_transaction_to_preview(transaction: dict[str, Any]) -> dict[str, Any]: info = _transaction_info(transaction) amount = _transaction_amount(transaction) currency = _currency_code(info) event_date = _transaction_date(info) return { "transaction_id": _transaction_id(info), "event_date": event_date.isoformat(), "updated_date": _date_string(info.get("transaction_updated_date")), "event_code": _string_or_none(info.get("transaction_event_code")), "paypal_status": _string_or_none(info.get("transaction_status")), "amount": str(abs(amount)) if amount is not None else None, "currency": currency, "counterparty": _counterparty(transaction), "description": _description(transaction), "event_type": _event_type(info, amount), "status": _event_status(info), } def paypal_transaction_to_observation( transaction: dict[str, Any], *, household_id: str | None, source_account_id: str | None, owner_user_id: str | None, ) -> dict[str, Any]: info = _transaction_info(transaction) amount = _transaction_amount(transaction) if amount is None: raise ValueError("PayPal transaction has no transaction_amount.value") transaction_id = _transaction_id(info) currency = _currency_code(info) event_date = _transaction_date(info) return { "household_id": household_id, "source_type": "paypal_api", "source_account_id": source_account_id, "owner_user_id": owner_user_id, "source_reference": transaction_id, "provider_transaction_id": transaction_id, "raw_payload_json": transaction, "amount": str(abs(amount)), "currency": currency, "event_date": event_date.isoformat(), "booking_date": _date_string(info.get("transaction_updated_date")), "merchant": _counterparty(transaction), "counterparty": _counterparty(transaction), "description": _description(transaction), "event_type": _event_type(info, amount), "status": _event_status(info), "payer_user_id": owner_user_id, "payment_account_id": source_account_id, } def _request_json(request: urllib.request.Request) -> dict[str, Any]: try: with urllib.request.urlopen(request, timeout=30) as response: data = response.read() except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise PayPalAPIError(_paypal_error_message(exc.code, detail)) from exc except urllib.error.URLError as exc: raise PayPalAPIError(f"PayPal request failed: {exc.reason}") from exc try: payload = json.loads(data.decode("utf-8")) except json.JSONDecodeError as exc: raise PayPalAPIError("PayPal returned invalid JSON.") from exc if not isinstance(payload, dict): raise PayPalAPIError("PayPal returned an unexpected JSON shape.") return payload def _paypal_error_message(status_code: int, detail: str) -> str: try: payload = json.loads(detail) except json.JSONDecodeError: return f"PayPal request failed with HTTP {status_code}: {detail[:500]}" message = payload.get("message") or payload.get("error_description") or payload.get("error") name = payload.get("name") if status_code == 403 and (name == "NOT_AUTHORIZED" or "insufficient permissions" in str(message).lower()): return ( "PayPal Transaction Search is not authorized for these credentials. " "Enable Transaction Search on the matching live/sandbox REST app, make sure you are using credentials " "for the same environment selected in Mousehold, and verify the OAuth token includes " f"{PAYPAL_TRANSACTION_SEARCH_SCOPE}. Third-party account access requires PayPal partner authorization." ) if name and message: return f"PayPal request failed with HTTP {status_code}: {name}: {message}" if message: return f"PayPal request failed with HTTP {status_code}: {message}" return f"PayPal request failed with HTTP {status_code}: {detail[:500]}" def _base_url(config: PayPalConfig) -> str: if config.base_url: return config.base_url.rstrip("/") return PAYPAL_SANDBOX_BASE_URL if config.environment == "sandbox" else PAYPAL_LIVE_BASE_URL def _validate_date_range(start_date: date, end_date: date) -> None: if end_date < start_date: raise PayPalAPIError("PayPal end_date must be on or after start_date.") if (end_date - start_date).days + 1 > PAYPAL_REPORTING_MAX_DAYS: raise PayPalAPIError("PayPal Transaction Search supports a maximum date range of 31 days.") def _paypal_datetime(value: date, *, end_of_day: bool) -> str: wall_time = time(23, 59, 59) if end_of_day else time(0, 0, 0) return datetime.combine(value, wall_time, tzinfo=UTC).isoformat().replace("+00:00", "Z") def _transaction_info(transaction: dict[str, Any]) -> dict[str, Any]: info = transaction.get("transaction_info") or {} return info if isinstance(info, dict) else {} def _transaction_amount(transaction: dict[str, Any]) -> Decimal | None: info = _transaction_info(transaction) amount = info.get("transaction_amount") or {} if not isinstance(amount, dict): return None value = amount.get("value") if value is None: return None try: return Decimal(str(value)).quantize(Decimal("0.01")) except InvalidOperation: return None def _currency_code(info: dict[str, Any]) -> str: amount = info.get("transaction_amount") or {} if isinstance(amount, dict): currency = amount.get("currency_code") if isinstance(currency, str) and currency: return currency.upper() return "EUR" def _transaction_id(info: dict[str, Any]) -> str | None: return _string_or_none(info.get("transaction_id")) or _string_or_none(info.get("paypal_reference_id")) def _transaction_date(info: dict[str, Any]) -> date: return _parse_paypal_date(info.get("transaction_initiation_date")) or date.today() def _date_string(value: object) -> str | None: parsed = _parse_paypal_date(value) return parsed.isoformat() if parsed else None def _parse_paypal_date(value: object) -> date | None: if not isinstance(value, str) or not value: return None normalized = value.replace("Z", "+00:00") if reworked := _parse_iso_date(normalized): return reworked for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"): try: return datetime.strptime(value, fmt).date() except ValueError: continue try: return date.fromisoformat(value[:10]) except ValueError: return None def _parse_iso_date(value: str) -> date | None: try: return datetime.fromisoformat(value).date() except ValueError: return None def _counterparty(transaction: dict[str, Any]) -> str | None: payer_info = transaction.get("payer_info") or {} if isinstance(payer_info, dict): payer_name = payer_info.get("payer_name") or {} if isinstance(payer_name, dict): full_name = _first_non_empty( payer_name.get("alternate_full_name"), " ".join( str(part) for part in (payer_name.get("given_name"), payer_name.get("surname")) if part ), ) if full_name: return full_name email = _string_or_none(payer_info.get("email_address")) if email: return email shipping_info = transaction.get("shipping_info") or {} if isinstance(shipping_info, dict): name = _string_or_none(shipping_info.get("name")) if name: return name return None def _description(transaction: dict[str, Any]) -> str | None: info = _transaction_info(transaction) cart_info = transaction.get("cart_info") or {} if isinstance(cart_info, dict): invoice_number = _string_or_none(cart_info.get("invoice_number")) if invoice_number: return f"PayPal invoice {invoice_number}" item_details = cart_info.get("item_details") or [] if isinstance(item_details, list) and item_details: first_item = item_details[0] if isinstance(first_item, dict): item_name = _string_or_none(first_item.get("item_name")) if item_name: return item_name return _first_non_empty( info.get("transaction_subject"), info.get("transaction_note"), info.get("paypal_reference_id"), info.get("transaction_id"), ) def _event_type(info: dict[str, Any], amount: Decimal | None) -> str: description = " ".join( str(value) for value in ( info.get("transaction_subject"), info.get("transaction_note"), info.get("transaction_event_code"), ) if value ).lower() if "fee" in description or "gebühr" in description: return "fee" if "refund" in description or "erstattung" in description or "rückerstattung" in description: return "refund" if amount is None: return "expense" return "expense" if amount < 0 else "income" def _event_status(info: dict[str, Any]) -> str: status = _string_or_none(info.get("transaction_status")) if status == "P": return "announced" if status == "D": return "cancelled" return "confirmed" def _string_or_none(value: object) -> str | None: if value is None: return None text = str(value).strip() return text or None def _first_non_empty(*values: object) -> str | None: for value in values: text = _string_or_none(value) if text: return text return None def _int_or_none(value: object) -> int | None: try: return int(str(value)) except (TypeError, ValueError): return None