kybernus 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/templates/java-spring/clean/src/main/java/{{packagePath}}/application/usecase/PaymentUseCase.java.hbs +89 -0
  3. package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/web/payment/PaymentController.java.hbs +78 -0
  4. package/templates/java-spring/clean/src/main/resources/application.properties.hbs +7 -0
  5. package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/inbound/web/PaymentController.java.hbs +78 -0
  6. package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/outbound/stripe/StripeAdapter.java.hbs +76 -0
  7. package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/core/service/PaymentService.java.hbs +90 -0
  8. package/templates/java-spring/hexagonal/src/main/resources/application.properties.hbs +7 -0
  9. package/templates/java-spring/mvc/src/main/java/{{packagePath}}/controller/PaymentsController.java.hbs +42 -53
  10. package/templates/java-spring/mvc/src/main/java/{{packagePath}}/service/StripeService.java.hbs +105 -23
  11. package/templates/nestjs/clean/src/app.module.ts.hbs +3 -1
  12. package/templates/nestjs/clean/src/application/payment.service.ts.hbs +90 -0
  13. package/templates/nestjs/clean/src/infrastructure/http/payment.controller.ts.hbs +46 -0
  14. package/templates/nestjs/clean/src/infrastructure/stripe.provider.ts.hbs +51 -0
  15. package/templates/nestjs/clean/src/main.ts.hbs +13 -4
  16. package/templates/nestjs/clean/src/payment.module.ts.hbs +23 -0
  17. package/templates/nestjs/hexagonal/src/adapters/inbound/payment.controller.ts.hbs +46 -0
  18. package/templates/nestjs/hexagonal/src/adapters/outbound/stripe.adapter.ts.hbs +54 -0
  19. package/templates/nestjs/hexagonal/src/app.module.ts.hbs +2 -0
  20. package/templates/nestjs/hexagonal/src/core/payment.service.ts.hbs +90 -0
  21. package/templates/nestjs/hexagonal/src/main.ts.hbs +13 -4
  22. package/templates/nestjs/hexagonal/src/payment.module.ts.hbs +23 -0
  23. package/templates/nestjs/mvc/src/main.ts.hbs +6 -3
  24. package/templates/nestjs/mvc/src/payments/payments.controller.ts.hbs +33 -8
  25. package/templates/nestjs/mvc/src/payments/payments.service.ts.hbs +66 -22
  26. package/templates/nextjs/mvc/src/app/api/checkout/route.ts.hbs +42 -13
  27. package/templates/nextjs/mvc/src/app/api/portal/route.ts.hbs +36 -0
  28. package/templates/nextjs/mvc/src/app/api/webhook/route.ts.hbs +32 -20
  29. package/templates/nodejs-express/clean/src/application/services/PaymentService.ts.hbs +98 -0
  30. package/templates/nodejs-express/clean/src/index.ts.hbs +29 -8
  31. package/templates/nodejs-express/clean/src/infrastructure/http/controllers/PaymentController.ts.hbs +57 -0
  32. package/templates/nodejs-express/clean/src/infrastructure/providers/StripeProvider.ts.hbs +45 -0
  33. package/templates/nodejs-express/hexagonal/src/adapters/inbound/http/PaymentController.ts.hbs +57 -0
  34. package/templates/nodejs-express/hexagonal/src/adapters/outbound/StripeAdapter.ts.hbs +48 -0
  35. package/templates/nodejs-express/hexagonal/src/core/PaymentService.ts.hbs +89 -0
  36. package/templates/nodejs-express/hexagonal/src/index.ts.hbs +28 -8
  37. package/templates/nodejs-express/mvc/src/app.ts.hbs +11 -2
  38. package/templates/nodejs-express/mvc/src/controllers/payments.controller.ts.hbs +31 -47
  39. package/templates/nodejs-express/mvc/src/services/stripe.service.ts.hbs +66 -49
  40. package/templates/python-fastapi/clean/app/application/services/payment_service.py.hbs +85 -0
  41. package/templates/python-fastapi/clean/app/infrastructure/http/payment_controller.py.hbs +64 -0
  42. package/templates/python-fastapi/clean/app/infrastructure/stripe_provider.py.hbs +44 -0
  43. package/templates/python-fastapi/clean/app/main.py.hbs +8 -5
  44. package/templates/python-fastapi/hexagonal/app/adapters/inbound/payment_http_adapter.py.hbs +64 -0
  45. package/templates/python-fastapi/hexagonal/app/adapters/outbound/stripe_adapter.py.hbs +44 -0
  46. package/templates/python-fastapi/hexagonal/app/core/payment_service.py.hbs +81 -0
  47. package/templates/python-fastapi/hexagonal/app/main.py.hbs +9 -3
  48. package/templates/python-fastapi/mvc/app/controllers/payments.py.hbs +70 -35
  49. package/templates/python-fastapi/mvc/app/services/stripe_service.py.hbs +58 -0
@@ -0,0 +1,85 @@
1
+ import os
2
+ from app.domain.repositories.user_repository import UserRepository
3
+ from app.infrastructure.stripe_provider import StripeProvider
4
+
5
+
6
+ class PaymentService:
7
+ def __init__(self, user_repository: UserRepository, stripe_provider: StripeProvider):
8
+ self.user_repository = user_repository
9
+ self.stripe_provider = stripe_provider
10
+
11
+ async def create_checkout_session(self, user_id: str, price_id: str) -> str:
12
+ """Return Stripe Checkout URL for the given user and price."""
13
+ user = await self.user_repository.find_by_id(user_id)
14
+ if not user:
15
+ raise ValueError("User not found")
16
+
17
+ customer_id = user.stripe_customer_id
18
+
19
+ if not customer_id:
20
+ customer = self.stripe_provider.create_customer(
21
+ email=user.email,
22
+ name=getattr(user, "name", None),
23
+ user_id=str(user.id),
24
+ )
25
+ customer_id = customer.id
26
+ user.stripe_customer_id = customer_id
27
+ await self.user_repository.save(user)
28
+
29
+ session = self.stripe_provider.create_checkout_session(
30
+ customer_id=customer_id,
31
+ price_id=price_id,
32
+ user_id=str(user_id),
33
+ success_url=f"{os.getenv('FRONTEND_URL')}/success?session_id={{CHECKOUT_SESSION_ID}}",
34
+ cancel_url=f"{os.getenv('FRONTEND_URL')}/cancel",
35
+ )
36
+ return session.url
37
+
38
+ async def create_portal_session(self, user_id: str) -> str:
39
+ """Return Stripe Portal URL for the given user."""
40
+ user = await self.user_repository.find_by_id(user_id)
41
+ if not user or not user.stripe_customer_id:
42
+ raise ValueError("No Stripe customer found for this user")
43
+
44
+ session = self.stripe_provider.create_portal_session(
45
+ customer_id=user.stripe_customer_id,
46
+ return_url=f"{os.getenv('FRONTEND_URL')}/dashboard",
47
+ )
48
+ return session.url
49
+
50
+ async def handle_webhook(self, payload: bytes, sig_header: str) -> dict:
51
+ """Validate and process a Stripe webhook event."""
52
+ try:
53
+ event = self.stripe_provider.construct_event(payload, sig_header)
54
+ except Exception as e:
55
+ raise ValueError(f"Webhook signature verification failed: {e}")
56
+
57
+ event_type = event["type"]
58
+ data_object = event["data"]["object"]
59
+
60
+ if event_type == "checkout.session.completed":
61
+ user_id = data_object.get("client_reference_id")
62
+ customer_id = data_object.get("customer")
63
+ if user_id and customer_id:
64
+ user = await self.user_repository.find_by_id(user_id)
65
+ if user:
66
+ user.stripe_customer_id = customer_id
67
+ await self.user_repository.save(user)
68
+ print(f"Checkout completed for user: {user_id}")
69
+
70
+ elif event_type == "customer.subscription.updated":
71
+ print(f"Subscription updated: {data_object.get('id')} | Status: {data_object.get('status')}")
72
+ # TODO: Update subscriptionStatus field on user
73
+
74
+ elif event_type == "customer.subscription.deleted":
75
+ print(f"Subscription deleted: {data_object.get('id')}")
76
+ # TODO: Mark user as unsubscribed
77
+
78
+ elif event_type == "invoice.payment_failed":
79
+ print(f"Payment failed for invoice: {data_object.get('id')}")
80
+ # TODO: Notify user via email
81
+
82
+ else:
83
+ print(f"Unhandled Stripe event: {event_type}")
84
+
85
+ return {"received": True}
@@ -0,0 +1,64 @@
1
+ from fastapi import APIRouter, HTTPException, Header, Request, Depends
2
+ from pydantic import BaseModel
3
+ from app.application.services.payment_service import PaymentService
4
+ from app.infrastructure.database.session import AsyncSessionLocal
5
+ from app.infrastructure.database.user_repository import SQLAlchemyUserRepository
6
+ from app.infrastructure.stripe_provider import StripeProvider
7
+ from app.infrastructure.security.jwt import get_current_user_id
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ def get_payment_service() -> PaymentService:
13
+ repo = SQLAlchemyUserRepository(AsyncSessionLocal())
14
+ provider = StripeProvider()
15
+ return PaymentService(user_repository=repo, stripe_provider=provider)
16
+
17
+
18
+ class CheckoutRequest(BaseModel):
19
+ price_id: str
20
+
21
+
22
+ @router.post("/checkout")
23
+ async def create_checkout(
24
+ data: CheckoutRequest,
25
+ user_id: str = Depends(get_current_user_id),
26
+ service: PaymentService = Depends(get_payment_service),
27
+ ):
28
+ """Create a Stripe Checkout Session (authenticated)."""
29
+ try:
30
+ url = await service.create_checkout_session(user_id=user_id, price_id=data.price_id)
31
+ return {"url": url}
32
+ except ValueError as e:
33
+ raise HTTPException(status_code=400, detail=str(e))
34
+
35
+
36
+ @router.post("/portal")
37
+ async def create_portal(
38
+ user_id: str = Depends(get_current_user_id),
39
+ service: PaymentService = Depends(get_payment_service),
40
+ ):
41
+ """Open Stripe Billing Portal (authenticated)."""
42
+ try:
43
+ url = await service.create_portal_session(user_id=user_id)
44
+ return {"url": url}
45
+ except ValueError as e:
46
+ raise HTTPException(status_code=400, detail=str(e))
47
+
48
+
49
+ @router.post("/webhook")
50
+ async def stripe_webhook(
51
+ request: Request,
52
+ stripe_signature: str = Header(None, alias="stripe-signature"),
53
+ service: PaymentService = Depends(get_payment_service),
54
+ ):
55
+ """Handle Stripe webhook events. No auth required – raw body."""
56
+ if not stripe_signature:
57
+ raise HTTPException(status_code=400, detail="Missing stripe-signature header")
58
+
59
+ payload = await request.body()
60
+ try:
61
+ result = await service.handle_webhook(payload=payload, sig_header=stripe_signature)
62
+ return result
63
+ except ValueError as e:
64
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,44 @@
1
+ import stripe
2
+ import os
3
+
4
+ stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
5
+ stripe.api_version = "2026-02-25.clover"
6
+
7
+
8
+ class StripeProvider:
9
+ """Infrastructure provider that wraps the Stripe SDK."""
10
+
11
+ def create_customer(self, email: str, name: str | None = None, user_id: str | None = None):
12
+ return stripe.Customer.create(
13
+ email=email,
14
+ name=name,
15
+ metadata={"userId": user_id} if user_id else {},
16
+ )
17
+
18
+ def create_checkout_session(
19
+ self,
20
+ customer_id: str,
21
+ price_id: str,
22
+ user_id: str,
23
+ success_url: str,
24
+ cancel_url: str,
25
+ ):
26
+ return stripe.checkout.Session.create(
27
+ mode="subscription",
28
+ payment_method_types=["card"],
29
+ line_items=[{"price": price_id, "quantity": 1}],
30
+ customer=customer_id,
31
+ success_url=success_url,
32
+ cancel_url=cancel_url,
33
+ client_reference_id=str(user_id),
34
+ )
35
+
36
+ def create_portal_session(self, customer_id: str, return_url: str):
37
+ return stripe.billing_portal.Session.create(
38
+ customer=customer_id,
39
+ return_url=return_url,
40
+ )
41
+
42
+ def construct_event(self, payload: bytes, sig_header: str):
43
+ webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
44
+ return stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
@@ -1,11 +1,12 @@
1
1
  from contextlib import asynccontextmanager
2
2
  from fastapi import FastAPI
3
- from app.infrastructure.http import auth_controller
3
+ from app.infrastructure.http import auth_controller, payment_controller
4
4
  from app.config import get_settings
5
5
  from app.infrastructure.database.session import engine, Base
6
6
 
7
7
  settings = get_settings()
8
8
 
9
+
9
10
  @asynccontextmanager
10
11
  async def lifespan(app: FastAPI):
11
12
  # Create tables on startup (for development)
@@ -13,20 +14,22 @@ async def lifespan(app: FastAPI):
13
14
  async with engine.begin() as conn:
14
15
  await conn.run_sync(Base.metadata.create_all)
15
16
  yield
16
- # Cleanup
17
17
  await engine.dispose()
18
18
 
19
+
19
20
  app = FastAPI(
20
21
  title="{{projectName}} - Clean Architecture",
21
- lifespan=lifespan
22
+ lifespan=lifespan,
22
23
  )
23
24
 
24
25
  app.include_router(auth_controller.router, prefix=settings.API_V1_STR + "/auth", tags=["Auth"])
26
+ app.include_router(payment_controller.router, prefix=settings.API_V1_STR + "/payments", tags=["Payments"])
27
+
25
28
 
26
29
  @app.get("/health")
27
30
  def health():
28
31
  return {
29
- "status": "ok",
32
+ "status": "ok",
30
33
  "architecture": "clean",
31
- "project": settings.PROJECT_NAME
34
+ "project": settings.PROJECT_NAME,
32
35
  }
@@ -0,0 +1,64 @@
1
+ from fastapi import APIRouter, HTTPException, Header, Request, Depends
2
+ from pydantic import BaseModel
3
+ from app.core.payment_service import PaymentService
4
+ from app.adapters.outbound.stripe_adapter import StripeAdapter
5
+ from app.infrastructure.database.session import AsyncSessionLocal
6
+ from app.infrastructure.database.user_repository import SQLAlchemyUserRepository
7
+ from app.infrastructure.security.jwt import get_current_user_id
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ def get_payment_service() -> PaymentService:
13
+ repo = SQLAlchemyUserRepository(AsyncSessionLocal())
14
+ adapter = StripeAdapter()
15
+ return PaymentService(user_repository=repo, stripe_adapter=adapter)
16
+
17
+
18
+ class CheckoutRequest(BaseModel):
19
+ price_id: str
20
+
21
+
22
+ @router.post("/checkout")
23
+ async def create_checkout(
24
+ data: CheckoutRequest,
25
+ user_id: str = Depends(get_current_user_id),
26
+ service: PaymentService = Depends(get_payment_service),
27
+ ):
28
+ """Create a Stripe Checkout Session (authenticated)."""
29
+ try:
30
+ url = await service.create_checkout_session(user_id=user_id, price_id=data.price_id)
31
+ return {"url": url}
32
+ except ValueError as e:
33
+ raise HTTPException(status_code=400, detail=str(e))
34
+
35
+
36
+ @router.post("/portal")
37
+ async def create_portal(
38
+ user_id: str = Depends(get_current_user_id),
39
+ service: PaymentService = Depends(get_payment_service),
40
+ ):
41
+ """Open Stripe Billing Portal (authenticated)."""
42
+ try:
43
+ url = await service.create_portal_session(user_id=user_id)
44
+ return {"url": url}
45
+ except ValueError as e:
46
+ raise HTTPException(status_code=400, detail=str(e))
47
+
48
+
49
+ @router.post("/webhook")
50
+ async def stripe_webhook(
51
+ request: Request,
52
+ stripe_signature: str = Header(None, alias="stripe-signature"),
53
+ service: PaymentService = Depends(get_payment_service),
54
+ ):
55
+ """Handle Stripe webhook events. No auth required – raw body."""
56
+ if not stripe_signature:
57
+ raise HTTPException(status_code=400, detail="Missing stripe-signature header")
58
+
59
+ payload = await request.body()
60
+ try:
61
+ result = await service.handle_webhook(payload=payload, sig_header=stripe_signature)
62
+ return result
63
+ except ValueError as e:
64
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,44 @@
1
+ import stripe
2
+ import os
3
+
4
+ stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
5
+ stripe.api_version = "2026-02-25.clover"
6
+
7
+
8
+ class StripeAdapter:
9
+ """Outbound adapter: wraps the Stripe SDK for use by the core domain."""
10
+
11
+ def create_customer(self, email: str, name: str | None = None, user_id: str | None = None):
12
+ return stripe.Customer.create(
13
+ email=email,
14
+ name=name,
15
+ metadata={"userId": user_id} if user_id else {},
16
+ )
17
+
18
+ def create_checkout_session(
19
+ self,
20
+ customer_id: str,
21
+ price_id: str,
22
+ user_id: str,
23
+ success_url: str,
24
+ cancel_url: str,
25
+ ):
26
+ return stripe.checkout.Session.create(
27
+ mode="subscription",
28
+ payment_method_types=["card"],
29
+ line_items=[{"price": price_id, "quantity": 1}],
30
+ customer=customer_id,
31
+ success_url=success_url,
32
+ cancel_url=cancel_url,
33
+ client_reference_id=str(user_id),
34
+ )
35
+
36
+ def create_portal_session(self, customer_id: str, return_url: str):
37
+ return stripe.billing_portal.Session.create(
38
+ customer=customer_id,
39
+ return_url=return_url,
40
+ )
41
+
42
+ def construct_event(self, payload: bytes, sig_header: str):
43
+ webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
44
+ return stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
@@ -0,0 +1,81 @@
1
+ import os
2
+ from app.core.ports.user_repository import UserRepository
3
+ from app.adapters.outbound.stripe_adapter import StripeAdapter
4
+
5
+
6
+ class PaymentService:
7
+ """Core domain service for payment operations (Hexagonal Architecture)."""
8
+
9
+ def __init__(self, user_repository: UserRepository, stripe_adapter: StripeAdapter):
10
+ self.user_repository = user_repository
11
+ self.stripe_adapter = stripe_adapter
12
+
13
+ async def create_checkout_session(self, user_id: str, price_id: str) -> str:
14
+ user = await self.user_repository.find_by_id(user_id)
15
+ if not user:
16
+ raise ValueError("User not found")
17
+
18
+ customer_id = user.stripe_customer_id
19
+
20
+ if not customer_id:
21
+ customer = self.stripe_adapter.create_customer(
22
+ email=user.email,
23
+ name=getattr(user, "name", None),
24
+ user_id=str(user.id),
25
+ )
26
+ customer_id = customer.id
27
+ user.stripe_customer_id = customer_id
28
+ await self.user_repository.save(user)
29
+
30
+ session = self.stripe_adapter.create_checkout_session(
31
+ customer_id=customer_id,
32
+ price_id=price_id,
33
+ user_id=str(user_id),
34
+ success_url=f"{os.getenv('FRONTEND_URL')}/success?session_id={{CHECKOUT_SESSION_ID}}",
35
+ cancel_url=f"{os.getenv('FRONTEND_URL')}/cancel",
36
+ )
37
+ return session.url
38
+
39
+ async def create_portal_session(self, user_id: str) -> str:
40
+ user = await self.user_repository.find_by_id(user_id)
41
+ if not user or not user.stripe_customer_id:
42
+ raise ValueError("No Stripe customer found for this user")
43
+
44
+ session = self.stripe_adapter.create_portal_session(
45
+ customer_id=user.stripe_customer_id,
46
+ return_url=f"{os.getenv('FRONTEND_URL')}/dashboard",
47
+ )
48
+ return session.url
49
+
50
+ async def handle_webhook(self, payload: bytes, sig_header: str) -> dict:
51
+ try:
52
+ event = self.stripe_adapter.construct_event(payload, sig_header)
53
+ except Exception as e:
54
+ raise ValueError(f"Webhook signature verification failed: {e}")
55
+
56
+ event_type = event["type"]
57
+ data_object = event["data"]["object"]
58
+
59
+ if event_type == "checkout.session.completed":
60
+ user_id = data_object.get("client_reference_id")
61
+ customer_id = data_object.get("customer")
62
+ if user_id and customer_id:
63
+ user = await self.user_repository.find_by_id(user_id)
64
+ if user:
65
+ user.stripe_customer_id = customer_id
66
+ await self.user_repository.save(user)
67
+ print(f"Checkout completed for user: {user_id}")
68
+
69
+ elif event_type == "customer.subscription.updated":
70
+ print(f"Subscription updated: {data_object.get('id')} | Status: {data_object.get('status')}")
71
+
72
+ elif event_type == "customer.subscription.deleted":
73
+ print(f"Subscription deleted: {data_object.get('id')}")
74
+
75
+ elif event_type == "invoice.payment_failed":
76
+ print(f"Payment failed for invoice: {data_object.get('id')}")
77
+
78
+ else:
79
+ print(f"Unhandled Stripe event: {event_type}")
80
+
81
+ return {"received": True}
@@ -1,30 +1,36 @@
1
1
  from contextlib import asynccontextmanager
2
2
  from fastapi import FastAPI
3
3
  from app.adapters.inbound.http_adapter import router as auth_router
4
+ from app.adapters.inbound.payment_http_adapter import router as payment_router
4
5
  from app.infrastructure.database.session import engine, Base
5
6
  from app.config import get_settings
6
7
 
7
8
  settings = get_settings()
8
9
 
10
+
9
11
  @asynccontextmanager
10
12
  async def lifespan(app: FastAPI):
11
13
  # Create tables on startup (for development)
14
+ # In production, use Alembic migrations
12
15
  async with engine.begin() as conn:
13
16
  await conn.run_sync(Base.metadata.create_all)
14
17
  yield
15
18
  await engine.dispose()
16
19
 
20
+
17
21
  app = FastAPI(
18
22
  title="{{projectName}} - Hexagonal Architecture",
19
- lifespan=lifespan
23
+ lifespan=lifespan,
20
24
  )
21
25
 
22
26
  app.include_router(auth_router, prefix=settings.API_V1_STR + "/auth", tags=["Auth"])
27
+ app.include_router(payment_router, prefix=settings.API_V1_STR + "/payments", tags=["Payments"])
28
+
23
29
 
24
30
  @app.get("/health")
25
31
  def health():
26
32
  return {
27
- "status": "ok",
33
+ "status": "ok",
28
34
  "architecture": "hexagonal",
29
- "project": settings.PROJECT_NAME
35
+ "project": settings.PROJECT_NAME,
30
36
  }
@@ -1,59 +1,94 @@
1
1
  from fastapi import APIRouter, HTTPException, Request, Depends
2
2
  from pydantic import BaseModel
3
3
  from sqlalchemy.orm import Session
4
- import stripe
5
4
  import os
6
5
  from app.database import get_db
6
+ from app.services.stripe_service import stripe_service
7
+ from app.models.user import User
8
+ from app.middleware.auth import get_current_user
7
9
 
8
10
  router = APIRouter()
9
11
 
10
- stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
11
- webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
12
12
 
13
13
  class CheckoutRequest(BaseModel):
14
14
  price_id: str
15
- customer_id: str | None = None
15
+
16
16
 
17
17
  @router.post("/checkout")
18
- async def create_checkout(data: CheckoutRequest):
19
- try:
20
- session = stripe.checkout.Session.create(
21
- mode="subscription",
22
- payment_method_types=["card"],
23
- line_items=[{"price": data.price_id, "quantity": 1}],
24
- customer=data.customer_id,
25
- success_url=f"{os.getenv('FRONTEND_URL')}/success?session_id={{CHECKOUT_SESSION_ID}}",
26
- cancel_url=f"{os.getenv('FRONTEND_URL')}/cancel",
27
- )
28
- return {"checkout_url": session.url, "session_id": session.id}
29
- except Exception as e:
30
- raise HTTPException(status_code=500, detail=str(e))
18
+ async def create_checkout(
19
+ data: CheckoutRequest,
20
+ db: Session = Depends(get_db),
21
+ current_user: User = Depends(get_current_user),
22
+ ):
23
+ """Create a Stripe Checkout Session (authenticated)."""
24
+ customer_id = stripe_service.get_or_create_customer(db, current_user)
25
+
26
+ session = stripe_service.create_checkout_session(
27
+ customer_id=customer_id,
28
+ price_id=data.price_id,
29
+ user_id=str(current_user.id),
30
+ success_url=f"{os.getenv('FRONTEND_URL')}/success?session_id={'{CHECKOUT_SESSION_ID}'}",
31
+ cancel_url=f"{os.getenv('FRONTEND_URL')}/cancel",
32
+ )
33
+ return {"url": session.url}
34
+
35
+
36
+ @router.post("/portal")
37
+ async def create_portal(
38
+ db: Session = Depends(get_db),
39
+ current_user: User = Depends(get_current_user),
40
+ ):
41
+ """Open Stripe Billing Portal (authenticated)."""
42
+ if not current_user.stripe_customer_id:
43
+ raise HTTPException(status_code=400, detail="No Stripe customer found for this user")
44
+
45
+ session = stripe_service.create_portal_session(
46
+ customer_id=current_user.stripe_customer_id,
47
+ return_url=f"{os.getenv('FRONTEND_URL')}/dashboard",
48
+ )
49
+ return {"url": session.url}
50
+
31
51
 
32
52
  @router.post("/webhook")
33
53
  async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
54
+ """Handle Stripe webhook events. No auth required – raw body."""
34
55
  payload = await request.body()
35
56
  sig_header = request.headers.get("stripe-signature")
36
57
 
58
+ if not sig_header:
59
+ raise HTTPException(status_code=400, detail="Missing stripe-signature header")
60
+
37
61
  try:
38
- event = stripe.Webhook.construct_event(
39
- payload, sig_header, webhook_secret
40
- )
41
- except ValueError as e:
42
- raise HTTPException(status_code=400, detail="Invalid payload")
43
- except stripe.error.SignatureVerificationError as e:
44
- raise HTTPException(status_code=400, detail="Invalid signature")
45
-
46
- event_type = event.get("type")
47
-
62
+ event = stripe_service.construct_event(payload, sig_header)
63
+ except Exception as e:
64
+ raise HTTPException(status_code=400, detail=f"Webhook error: {str(e)}")
65
+
66
+ event_type = event["type"]
67
+ data_object = event["data"]["object"]
68
+
48
69
  if event_type == "checkout.session.completed":
49
- session = event.get("data").get("object")
50
- print(f"Checkout completed: {session.get('id')}")
51
- # Update user in db
70
+ user_id = data_object.get("client_reference_id")
71
+ customer_id = data_object.get("customer")
72
+ if user_id and customer_id:
73
+ user = db.query(User).filter(User.id == user_id).first()
74
+ if user:
75
+ user.stripe_customer_id = customer_id
76
+ db.commit()
77
+ print(f"Checkout completed for user: {user_id}")
78
+
52
79
  elif event_type == "customer.subscription.updated":
53
- subscription = event.get("data").get("object")
54
- print(f"Subscription updated: {subscription.get('id')}")
80
+ print(f"Subscription updated: {data_object.get('id')} | Status: {data_object.get('status')}")
81
+ # TODO: Update subscription status in DB
82
+
55
83
  elif event_type == "customer.subscription.deleted":
56
- subscription = event.get("data").get("object")
57
- print(f"Subscription deleted: {subscription.get('id')}")
58
-
84
+ print(f"Subscription deleted: {data_object.get('id')}")
85
+ # TODO: Mark user as unsubscribed in DB
86
+
87
+ elif event_type == "invoice.payment_failed":
88
+ print(f"Payment failed for invoice: {data_object.get('id')}")
89
+ # TODO: Notify user via email
90
+
91
+ else:
92
+ print(f"Unhandled Stripe event: {event_type}")
93
+
59
94
  return {"received": True}
@@ -0,0 +1,58 @@
1
+ import stripe
2
+ import os
3
+ from sqlalchemy.orm import Session
4
+ from app.models.user import User
5
+
6
+ stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
7
+ stripe.api_version = "2026-02-25.clover"
8
+
9
+
10
+ class StripeService:
11
+ def get_or_create_customer(self, db: Session, user: User) -> str:
12
+ """Return existing stripeCustomerId or create a new Stripe customer."""
13
+ if user.stripe_customer_id:
14
+ return user.stripe_customer_id
15
+
16
+ customer = stripe.Customer.create(
17
+ email=user.email,
18
+ name=getattr(user, "name", None),
19
+ metadata={"userId": str(user.id)},
20
+ )
21
+ user.stripe_customer_id = customer.id
22
+ db.commit()
23
+ db.refresh(user)
24
+ return customer.id
25
+
26
+ def create_checkout_session(
27
+ self,
28
+ customer_id: str,
29
+ price_id: str,
30
+ user_id: str,
31
+ success_url: str,
32
+ cancel_url: str,
33
+ ):
34
+ """Create a Stripe Checkout Session for a subscription."""
35
+ return stripe.checkout.Session.create(
36
+ mode="subscription",
37
+ payment_method_types=["card"],
38
+ line_items=[{"price": price_id, "quantity": 1}],
39
+ customer=customer_id,
40
+ success_url=success_url,
41
+ cancel_url=cancel_url,
42
+ client_reference_id=str(user_id),
43
+ )
44
+
45
+ def create_portal_session(self, customer_id: str, return_url: str):
46
+ """Create a Stripe Billing Portal session."""
47
+ return stripe.billing_portal.Session.create(
48
+ customer=customer_id,
49
+ return_url=return_url,
50
+ )
51
+
52
+ def construct_event(self, payload: bytes, sig_header: str):
53
+ """Validate and construct a Stripe webhook event."""
54
+ webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
55
+ return stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
56
+
57
+
58
+ stripe_service = StripeService()