diff --git a/env_dev.env b/env_dev.env index a6e656af..a67e59df 100644 --- a/env_dev.env +++ b/env_dev.env @@ -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 diff --git a/env_int.env b/env_int.env index 85aa7976..9c0823a5 100644 --- a/env_int.env +++ b/env_int.env @@ -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 diff --git a/env_prod.env b/env_prod.env index 956923f1..c1b77bfe 100644 --- a/env_prod.env +++ b/env_prod.env @@ -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 diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py index ea5a4c00..2d78d95b 100644 --- a/modules/datamodels/datamodelBilling.py +++ b/modules/datamodels/datamodelBilling.py @@ -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( diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 07261d99..8ffd7254 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -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 # ========================================================================= diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index a3800753..2f4aea86 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -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( diff --git a/modules/services/serviceBilling/stripeCheckout.py b/modules/services/serviceBilling/stripeCheckout.py new file mode 100644 index 00000000..06853e72 --- /dev/null +++ b/modules/services/serviceBilling/stripeCheckout.py @@ -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 diff --git a/requirements.txt b/requirements.txt index f2b0b02f..cf6fe53a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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)