feat: integrated stripe payment
This commit is contained in:
parent
f5143611b0
commit
7578d8bf3e
8 changed files with 336 additions and 3 deletions
|
|
@ -35,6 +35,12 @@ APP_LOGGING_BACKUP_COUNT = 5
|
|||
Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback
|
||||
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback
|
||||
|
||||
# Stripe Billing
|
||||
STRIPE_SECRET_KEY = sk_test_51T4cVa4OUoIL0OsjQRDsyvRSA3qZthU6uSZvxkxEq0OYGrKznxWf8uBC3v8vtOqzMqZ2NDB3MHJjcKY1ydPLpotE00RuJPQx91
|
||||
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
APP_FRONTEND_URL = http://localhost:5176
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ APP_LOGGING_BACKUP_COUNT = 5
|
|||
Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback
|
||||
Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback
|
||||
|
||||
# Stripe Billing
|
||||
STRIPE_SECRET_KEY = sk_test_51T4cVa4OUoIL0OsjQRDsyvRSA3qZthU6uSZvxkxEq0OYGrKznxWf8uBC3v8vtOqzMqZ2NDB3MHJjcKY1ydPLpotE00RuJPQx91
|
||||
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
APP_FRONTEND_URL = https://nyla-int.poweron-center.net
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ APP_LOGGING_BACKUP_COUNT = 5
|
|||
Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback
|
||||
Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback
|
||||
|
||||
# Stripe Billing
|
||||
STRIPE_SECRET_KEY = sk_test_51T4cVa4OUoIL0OsjQRDsyvRSA3qZthU6uSZvxkxEq0OYGrKznxWf8uBC3v8vtOqzMqZ2NDB3MHJjcKY1ydPLpotE00RuJPQx91
|
||||
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
APP_FRONTEND_URL = https://nyla.poweron-center.net
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from enum import Enum
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
import uuid
|
||||
|
|
@ -184,6 +184,18 @@ registerModelLabels(
|
|||
)
|
||||
|
||||
|
||||
class StripeWebhookEvent(BaseModel):
|
||||
"""Stores processed Stripe webhook event IDs for idempotency."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||
)
|
||||
event_id: str = Field(..., description="Stripe event ID (evt_xxx)")
|
||||
processed_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
description="When the event was processed"
|
||||
)
|
||||
|
||||
|
||||
class UsageStatistics(BaseModel):
|
||||
"""Aggregated usage statistics for quick retrieval."""
|
||||
id: str = Field(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from modules.datamodels.datamodelBilling import (
|
|||
BillingAccount,
|
||||
BillingTransaction,
|
||||
BillingSettings,
|
||||
StripeWebhookEvent,
|
||||
UsageStatistics,
|
||||
BillingAddress,
|
||||
BillingModelEnum,
|
||||
|
|
@ -642,6 +643,43 @@ class BillingObjects:
|
|||
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||
return allTransactions[:limit]
|
||||
|
||||
# =========================================================================
|
||||
# StripeWebhookEvent Operations (idempotency)
|
||||
# =========================================================================
|
||||
|
||||
def getStripeWebhookEventByEventId(self, event_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Check if a Stripe event has already been processed (idempotency).
|
||||
|
||||
Args:
|
||||
event_id: Stripe event ID (evt_xxx)
|
||||
|
||||
Returns:
|
||||
Event record if exists, else None
|
||||
"""
|
||||
try:
|
||||
results = self.db.getRecordset(
|
||||
StripeWebhookEvent,
|
||||
recordFilter={"event_id": event_id}
|
||||
)
|
||||
return results[0] if results else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Stripe webhook event: {e}")
|
||||
return None
|
||||
|
||||
def createStripeWebhookEvent(self, event_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Record that a Stripe event has been processed.
|
||||
|
||||
Args:
|
||||
event_id: Stripe event ID (evt_xxx)
|
||||
|
||||
Returns:
|
||||
Created event record
|
||||
"""
|
||||
record = StripeWebhookEvent(event_id=event_id)
|
||||
return self.db.recordCreate(StripeWebhookEvent, record.model_dump())
|
||||
|
||||
# =========================================================================
|
||||
# Balance Check Operations
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Features:
|
|||
- Admin endpoints: Manage settings, add credits, view all accounts
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
|
|
@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
|
|||
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||
|
||||
# Import billing components
|
||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface, _getRootInterface
|
||||
from modules.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
from modules.routes.routeDataUsers import _applyFiltersAndSort
|
||||
|
|
@ -215,6 +215,17 @@ class CreditAddRequest(BaseModel):
|
|||
description: str = Field(default="Manual credit", description="Transaction description")
|
||||
|
||||
|
||||
class CheckoutCreateRequest(BaseModel):
|
||||
"""Request model for creating Stripe Checkout Session."""
|
||||
userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
|
||||
amount: float = Field(..., gt=0, description="Amount to pay in CHF (must be in allowed presets)")
|
||||
|
||||
|
||||
class CheckoutCreateResponse(BaseModel):
|
||||
"""Response model for Checkout Session creation."""
|
||||
redirectUrl: str = Field(..., description="Stripe Checkout URL for redirect")
|
||||
|
||||
|
||||
class BillingSettingsUpdate(BaseModel):
|
||||
"""Request model for updating billing settings."""
|
||||
billingModel: Optional[BillingModelEnum] = None
|
||||
|
|
@ -702,6 +713,153 @@ def addCredit(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/checkout/create/{targetMandateId}", response_model=CheckoutCreateResponse)
|
||||
@limiter.limit("10/minute")
|
||||
def createCheckoutSession(
|
||||
request: Request,
|
||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
checkoutRequest: CheckoutCreateRequest = Body(...),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
_admin = Depends(requireSysAdminRole)
|
||||
):
|
||||
"""
|
||||
Create Stripe Checkout Session for credit top-up. Returns redirect URL.
|
||||
SysAdmin only. Amount is validated server-side against allowed presets.
|
||||
"""
|
||||
try:
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
settings = billingInterface.getSettings(targetMandateId)
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
||||
|
||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
if not checkoutRequest.userId:
|
||||
raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
|
||||
elif billingModel not in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
|
||||
|
||||
from modules.services.serviceBilling.stripeCheckout import create_checkout_session
|
||||
redirect_url = create_checkout_session(
|
||||
mandate_id=targetMandateId,
|
||||
user_id=checkoutRequest.userId,
|
||||
amount_chf=checkoutRequest.amount
|
||||
)
|
||||
return CheckoutCreateResponse(redirectUrl=redirect_url)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating checkout session: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/webhook/stripe")
|
||||
async def stripeWebhook(
|
||||
request: Request,
|
||||
stripe_signature: Optional[str] = Header(None, alias="Stripe-Signature")
|
||||
):
|
||||
"""
|
||||
Stripe webhook endpoint. Verifies signature and processes checkout.session.completed.
|
||||
No JWT auth - Stripe authenticates via Stripe-Signature header.
|
||||
"""
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF
|
||||
|
||||
webhook_secret = APP_CONFIG.get("STRIPE_WEBHOOK_SECRET")
|
||||
if not webhook_secret:
|
||||
logger.error("STRIPE_WEBHOOK_SECRET not configured")
|
||||
raise HTTPException(status_code=500, detail="Webhook not configured")
|
||||
|
||||
if not stripe_signature:
|
||||
raise HTTPException(status_code=400, detail="Missing Stripe-Signature header")
|
||||
|
||||
payload = await request.body()
|
||||
|
||||
try:
|
||||
import stripe
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, stripe_signature, webhook_secret
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Stripe webhook invalid payload: {e}")
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
except Exception as e:
|
||||
logger.warning(f"Stripe webhook signature verification failed: {e}")
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
if event.type != "checkout.session.completed":
|
||||
return {"received": True}
|
||||
|
||||
session = event.data.object
|
||||
event_id = event.id
|
||||
session_id = session.id
|
||||
|
||||
billingInterface = _getRootInterface()
|
||||
|
||||
if billingInterface.getStripeWebhookEventByEventId(event_id):
|
||||
logger.info(f"Stripe event {event_id} already processed, skipping")
|
||||
return {"received": True}
|
||||
|
||||
metadata = session.get("metadata") or {}
|
||||
mandate_id = metadata.get("mandateId")
|
||||
user_id = metadata.get("userId") or None
|
||||
amount_chf_str = metadata.get("amountChf", "0")
|
||||
|
||||
if not mandate_id:
|
||||
logger.error(f"Stripe webhook missing mandateId in session {session_id}")
|
||||
raise HTTPException(status_code=400, detail="Invalid session metadata")
|
||||
|
||||
try:
|
||||
amount_chf = float(amount_chf_str)
|
||||
except (TypeError, ValueError):
|
||||
amount_chf = None
|
||||
|
||||
if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF:
|
||||
amount_total = session.get("amount_total")
|
||||
if amount_total is not None:
|
||||
amount_chf = amount_total / 100.0
|
||||
else:
|
||||
logger.error(f"Stripe webhook invalid amount for session {session_id}")
|
||||
raise HTTPException(status_code=400, detail="Invalid amount")
|
||||
|
||||
settings = billingInterface.getSettings(mandate_id)
|
||||
if not settings:
|
||||
logger.error(f"Stripe webhook: billing settings not found for mandate {mandate_id}")
|
||||
raise HTTPException(status_code=404, detail="Billing settings not found")
|
||||
|
||||
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
|
||||
if billing_model == BillingModelEnum.PREPAY_USER:
|
||||
if not user_id:
|
||||
logger.error(f"Stripe webhook: userId required for PREPAY_USER mandate {mandate_id}")
|
||||
raise HTTPException(status_code=400, detail="userId required")
|
||||
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
|
||||
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
||||
else:
|
||||
logger.error(f"Stripe webhook: cannot credit mandate {mandate_id} with model {billing_model}")
|
||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
||||
|
||||
transaction = BillingTransaction(
|
||||
accountId=account["id"],
|
||||
transactionType=TransactionTypeEnum.CREDIT,
|
||||
amount=amount_chf,
|
||||
description="Stripe-Zahlung",
|
||||
referenceType=ReferenceTypeEnum.PAYMENT,
|
||||
referenceId=session_id
|
||||
)
|
||||
billingInterface.createTransaction(transaction)
|
||||
billingInterface.createStripeWebhookEvent(event_id)
|
||||
|
||||
logger.info(f"Stripe webhook: credited {amount_chf} CHF to account {account['id']} (session {session_id})")
|
||||
return {"received": True}
|
||||
|
||||
|
||||
@router.get("/admin/accounts/{targetMandateId}", response_model=List[AccountSummary])
|
||||
@limiter.limit("30/minute")
|
||||
def getAccounts(
|
||||
|
|
|
|||
104
modules/services/serviceBilling/stripeCheckout.py
Normal file
104
modules/services/serviceBilling/stripeCheckout.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# 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 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 create_checkout_session(
|
||||
mandate_id: str,
|
||||
user_id: Optional[str],
|
||||
amount_chf: float
|
||||
) -> 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") or APP_CONFIG.get("STRIPE_SECRET_KEY_SECRET")
|
||||
if not secret_key:
|
||||
raise ValueError("STRIPE_SECRET_KEY or STRIPE_SECRET_KEY_SECRET not configured")
|
||||
|
||||
stripe.api_key = secret_key
|
||||
|
||||
frontend_url = APP_CONFIG.get("APP_FRONTEND_URL", "https://nyla-int.poweron-center.net")
|
||||
base_path = "/admin/billing"
|
||||
success_url = f"{frontend_url.rstrip('/')}{base_path}?success=true&session_id={{CHECKOUT_SESSION_ID}}"
|
||||
cancel_url = f"{frontend_url.rstrip('/')}{base_path}?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
|
||||
|
|
@ -105,6 +105,9 @@ xyzservices>=2021.09.1
|
|||
psycopg2-binary==2.9.9
|
||||
asyncpg==0.30.0
|
||||
|
||||
## Stripe payments
|
||||
stripe>=11.0.0
|
||||
|
||||
## Geospatial libraries for STAC connector
|
||||
pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326)
|
||||
shapely>=2.0.0 # For geometric operations (intersections, area calculations)
|
||||
|
|
|
|||
Loading…
Reference in a new issue