first ruggedy draft
This commit is contained in:
459
apps/local-agent/mousehold_agent/paypal_connector.py
Normal file
459
apps/local-agent/mousehold_agent/paypal_connector.py
Normal file
@@ -0,0 +1,459 @@
|
||||
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
|
||||
Reference in New Issue
Block a user