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.
- package/package.json +1 -1
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/application/usecase/PaymentUseCase.java.hbs +89 -0
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/web/payment/PaymentController.java.hbs +78 -0
- package/templates/java-spring/clean/src/main/resources/application.properties.hbs +7 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/inbound/web/PaymentController.java.hbs +78 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/outbound/stripe/StripeAdapter.java.hbs +76 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/core/service/PaymentService.java.hbs +90 -0
- package/templates/java-spring/hexagonal/src/main/resources/application.properties.hbs +7 -0
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/controller/PaymentsController.java.hbs +42 -53
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/service/StripeService.java.hbs +105 -23
- package/templates/nestjs/clean/src/app.module.ts.hbs +3 -1
- package/templates/nestjs/clean/src/application/payment.service.ts.hbs +90 -0
- package/templates/nestjs/clean/src/infrastructure/http/payment.controller.ts.hbs +46 -0
- package/templates/nestjs/clean/src/infrastructure/stripe.provider.ts.hbs +51 -0
- package/templates/nestjs/clean/src/main.ts.hbs +13 -4
- package/templates/nestjs/clean/src/payment.module.ts.hbs +23 -0
- package/templates/nestjs/hexagonal/src/adapters/inbound/payment.controller.ts.hbs +46 -0
- package/templates/nestjs/hexagonal/src/adapters/outbound/stripe.adapter.ts.hbs +54 -0
- package/templates/nestjs/hexagonal/src/app.module.ts.hbs +2 -0
- package/templates/nestjs/hexagonal/src/core/payment.service.ts.hbs +90 -0
- package/templates/nestjs/hexagonal/src/main.ts.hbs +13 -4
- package/templates/nestjs/hexagonal/src/payment.module.ts.hbs +23 -0
- package/templates/nestjs/mvc/src/main.ts.hbs +6 -3
- package/templates/nestjs/mvc/src/payments/payments.controller.ts.hbs +33 -8
- package/templates/nestjs/mvc/src/payments/payments.service.ts.hbs +66 -22
- package/templates/nextjs/mvc/src/app/api/checkout/route.ts.hbs +42 -13
- package/templates/nextjs/mvc/src/app/api/portal/route.ts.hbs +36 -0
- package/templates/nextjs/mvc/src/app/api/webhook/route.ts.hbs +32 -20
- package/templates/nodejs-express/clean/src/application/services/PaymentService.ts.hbs +98 -0
- package/templates/nodejs-express/clean/src/index.ts.hbs +29 -8
- package/templates/nodejs-express/clean/src/infrastructure/http/controllers/PaymentController.ts.hbs +57 -0
- package/templates/nodejs-express/clean/src/infrastructure/providers/StripeProvider.ts.hbs +45 -0
- package/templates/nodejs-express/hexagonal/src/adapters/inbound/http/PaymentController.ts.hbs +57 -0
- package/templates/nodejs-express/hexagonal/src/adapters/outbound/StripeAdapter.ts.hbs +48 -0
- package/templates/nodejs-express/hexagonal/src/core/PaymentService.ts.hbs +89 -0
- package/templates/nodejs-express/hexagonal/src/index.ts.hbs +28 -8
- package/templates/nodejs-express/mvc/src/app.ts.hbs +11 -2
- package/templates/nodejs-express/mvc/src/controllers/payments.controller.ts.hbs +31 -47
- package/templates/nodejs-express/mvc/src/services/stripe.service.ts.hbs +66 -49
- package/templates/python-fastapi/clean/app/application/services/payment_service.py.hbs +85 -0
- package/templates/python-fastapi/clean/app/infrastructure/http/payment_controller.py.hbs +64 -0
- package/templates/python-fastapi/clean/app/infrastructure/stripe_provider.py.hbs +44 -0
- package/templates/python-fastapi/clean/app/main.py.hbs +8 -5
- package/templates/python-fastapi/hexagonal/app/adapters/inbound/payment_http_adapter.py.hbs +64 -0
- package/templates/python-fastapi/hexagonal/app/adapters/outbound/stripe_adapter.py.hbs +44 -0
- package/templates/python-fastapi/hexagonal/app/core/payment_service.py.hbs +81 -0
- package/templates/python-fastapi/hexagonal/app/main.py.hbs +9 -3
- package/templates/python-fastapi/mvc/app/controllers/payments.py.hbs +70 -35
- 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
|
-
|
|
15
|
+
|
|
16
16
|
|
|
17
17
|
@router.post("/checkout")
|
|
18
|
-
async def create_checkout(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 =
|
|
39
|
-
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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()
|