feat: integrated stripe payment

This commit is contained in:
Ida Dittrich 2026-02-25 08:44:12 +01:00
parent f5143611b0
commit 7578d8bf3e
8 changed files with 336 additions and 3 deletions

View file

@ -35,6 +35,12 @@ APP_LOGGING_BACKUP_COUNT = 5
Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/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 # AI configuration
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9 Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09 Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09

View file

@ -35,6 +35,12 @@ APP_LOGGING_BACKUP_COUNT = 5
Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback 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 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 # AI configuration
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9 Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09 Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09

View file

@ -35,6 +35,12 @@ APP_LOGGING_BACKUP_COUNT = 5
Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback 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 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 # AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9 Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09 Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09

View file

@ -4,7 +4,7 @@
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from enum import Enum from enum import Enum
from datetime import date, datetime from datetime import date, datetime, timezone
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
import uuid 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): class UsageStatistics(BaseModel):
"""Aggregated usage statistics for quick retrieval.""" """Aggregated usage statistics for quick retrieval."""
id: str = Field( id: str = Field(

View file

@ -21,6 +21,7 @@ from modules.datamodels.datamodelBilling import (
BillingAccount, BillingAccount,
BillingTransaction, BillingTransaction,
BillingSettings, BillingSettings,
StripeWebhookEvent,
UsageStatistics, UsageStatistics,
BillingAddress, BillingAddress,
BillingModelEnum, BillingModelEnum,
@ -642,6 +643,43 @@ class BillingObjects:
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True) allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
return allTransactions[:limit] 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 # Balance Check Operations
# ========================================================================= # =========================================================================

View file

@ -9,7 +9,7 @@ Features:
- Admin endpoints: Manage settings, add credits, view all accounts - 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 typing import List, Dict, Any, Optional
from fastapi import status from fastapi import status
import logging import logging
@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
# Import billing components # 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.services.serviceBilling.mainServiceBilling import getService as getBillingService
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeDataUsers import _applyFiltersAndSort from modules.routes.routeDataUsers import _applyFiltersAndSort
@ -215,6 +215,17 @@ class CreditAddRequest(BaseModel):
description: str = Field(default="Manual credit", description="Transaction description") 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): class BillingSettingsUpdate(BaseModel):
"""Request model for updating billing settings.""" """Request model for updating billing settings."""
billingModel: Optional[BillingModelEnum] = None billingModel: Optional[BillingModelEnum] = None
@ -702,6 +713,153 @@ def addCredit(
raise HTTPException(status_code=500, detail=str(e)) 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]) @router.get("/admin/accounts/{targetMandateId}", response_model=List[AccountSummary])
@limiter.limit("30/minute") @limiter.limit("30/minute")
def getAccounts( def getAccounts(

View 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

View file

@ -105,6 +105,9 @@ xyzservices>=2021.09.1
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
asyncpg==0.30.0 asyncpg==0.30.0
## Stripe payments
stripe>=11.0.0
## Geospatial libraries for STAC connector ## Geospatial libraries for STAC connector
pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326) pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326)
shapely>=2.0.0 # For geometric operations (intersections, area calculations) shapely>=2.0.0 # For geometric operations (intersections, area calculations)