first ruggedy draft

This commit is contained in:
2026-06-28 13:48:15 +02:00
parent c916f64fcb
commit 1053f6a2a3
78 changed files with 20905 additions and 2 deletions

View 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