140 lines
4.3 KiB
Python
140 lines
4.3 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Stripe Checkout service for billing credit top-ups.
|
|
Creates Checkout Sessions for redirect-based payment flow.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
from modules.shared.configuration import APP_CONFIG
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Server-side allowed amounts in CHF - never trust client
|
|
ALLOWED_AMOUNTS_CHF = [10, 25, 50, 100, 250, 500]
|
|
|
|
|
|
def _normalizeReturnUrl(returnUrl: str) -> str:
|
|
"""
|
|
Validate and normalize an absolute frontend return URL.
|
|
|
|
Allowed examples:
|
|
- https://nyla.poweron-center.net/billing/transactions
|
|
- https://nyla-int.poweron-center.net/billing/transactions?tab=overview
|
|
"""
|
|
if not returnUrl:
|
|
raise ValueError("returnUrl is required")
|
|
|
|
parsed = urlsplit(returnUrl.strip())
|
|
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise ValueError("returnUrl must use http or https")
|
|
|
|
if not parsed.netloc:
|
|
raise ValueError("returnUrl must contain a host")
|
|
|
|
if parsed.username or parsed.password:
|
|
raise ValueError("returnUrl must not contain credentials")
|
|
|
|
query_items = [
|
|
(key, value)
|
|
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
if key not in {"success", "canceled", "session_id"}
|
|
]
|
|
normalized_query = urlencode(query_items, doseq=True)
|
|
normalized_path = parsed.path or "/"
|
|
|
|
# Keep scheme + host + path + query, strip fragment for deterministic redirects.
|
|
return urlunsplit((parsed.scheme, parsed.netloc, normalized_path, normalized_query, ""))
|
|
|
|
|
|
def create_checkout_session(
|
|
mandate_id: str,
|
|
user_id: Optional[str],
|
|
amount_chf: float,
|
|
return_url: str
|
|
) -> str:
|
|
"""
|
|
Create a Stripe Checkout Session for credit top-up.
|
|
|
|
Amount and currency are validated server-side. The client-provided amount
|
|
must match an allowed preset.
|
|
|
|
Args:
|
|
mandate_id: Target mandate ID
|
|
user_id: Target user ID (for PREPAY_USER) or None (for mandate pool)
|
|
amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
|
|
|
|
Returns:
|
|
Stripe Checkout Session URL for redirect
|
|
|
|
Raises:
|
|
ValueError: If amount is invalid
|
|
"""
|
|
import stripe
|
|
|
|
# Validate amount server-side
|
|
if amount_chf not in ALLOWED_AMOUNTS_CHF:
|
|
raise ValueError(
|
|
f"Invalid amount {amount_chf} CHF. Allowed: {ALLOWED_AMOUNTS_CHF}"
|
|
)
|
|
|
|
# Pin API version from config (match Stripe Dashboard)
|
|
api_version = APP_CONFIG.get("STRIPE_API_VERSION")
|
|
if api_version:
|
|
stripe.api_version = api_version
|
|
|
|
# Get secrets
|
|
secret_key = APP_CONFIG.get("STRIPE_SECRET_KEY_SECRET") or APP_CONFIG.get("STRIPE_SECRET_KEY")
|
|
if not secret_key:
|
|
raise ValueError("STRIPE_SECRET_KEY_SECRET not configured")
|
|
|
|
stripe.api_key = secret_key
|
|
|
|
base_return_url = _normalizeReturnUrl(return_url)
|
|
query_separator = "&" if "?" in base_return_url else "?"
|
|
success_url = f"{base_return_url}{query_separator}success=true&session_id={{CHECKOUT_SESSION_ID}}"
|
|
cancel_url = f"{base_return_url}{query_separator}canceled=true"
|
|
|
|
# Amount in cents for Stripe (CHF uses 2 decimal places)
|
|
amount_cents = int(round(amount_chf * 100))
|
|
|
|
metadata = {
|
|
"mandateId": mandate_id,
|
|
"amountChf": str(amount_chf),
|
|
}
|
|
if user_id:
|
|
metadata["userId"] = user_id
|
|
|
|
session = stripe.checkout.Session.create(
|
|
mode="payment",
|
|
line_items=[
|
|
{
|
|
"price_data": {
|
|
"currency": "chf",
|
|
"unit_amount": amount_cents,
|
|
"product_data": {
|
|
"name": "Guthaben aufladen",
|
|
"description": "AI Service Guthaben (CHF)",
|
|
},
|
|
},
|
|
"quantity": 1,
|
|
}
|
|
],
|
|
success_url=success_url,
|
|
cancel_url=cancel_url,
|
|
metadata=metadata,
|
|
)
|
|
|
|
if not session or not session.url:
|
|
raise ValueError("Stripe Checkout Session creation failed")
|
|
|
|
logger.info(
|
|
f"Created Stripe Checkout Session {session.id} for mandate {mandate_id}, "
|
|
f"amount {amount_chf} CHF"
|
|
)
|
|
|
|
return session.url
|