kybernus 2.2.1 → 2.4.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 (97) hide show
  1. package/dist/cli/prompts/wizard.d.ts.map +1 -1
  2. package/dist/cli/prompts/wizard.js +22 -4
  3. package/dist/cli/prompts/wizard.js.map +1 -1
  4. package/dist/core/ai/prompts/documentation-prompts.d.ts.map +1 -1
  5. package/dist/core/ai/prompts/documentation-prompts.js +6 -0
  6. package/dist/core/ai/prompts/documentation-prompts.js.map +1 -1
  7. package/dist/core/generator/project.d.ts.map +1 -1
  8. package/dist/core/generator/project.js +6 -1
  9. package/dist/core/generator/project.js.map +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/models/config.d.ts +1 -1
  13. package/dist/models/config.d.ts.map +1 -1
  14. package/package.json +1 -1
  15. package/templates/java-spring/clean/src/main/java/{{packagePath}}/application/usecase/PaymentUseCase.java.hbs +89 -0
  16. package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/web/payment/PaymentController.java.hbs +78 -0
  17. package/templates/java-spring/clean/src/main/resources/application.properties.hbs +7 -0
  18. package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/inbound/web/PaymentController.java.hbs +78 -0
  19. package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/outbound/stripe/StripeAdapter.java.hbs +76 -0
  20. package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/core/service/PaymentService.java.hbs +90 -0
  21. package/templates/java-spring/hexagonal/src/main/resources/application.properties.hbs +7 -0
  22. package/templates/java-spring/mvc/src/main/java/{{packagePath}}/controller/PaymentsController.java.hbs +42 -53
  23. package/templates/java-spring/mvc/src/main/java/{{packagePath}}/service/StripeService.java.hbs +105 -23
  24. package/templates/n8n/ai-assistant/.env.example.hbs +14 -0
  25. package/templates/n8n/ai-assistant/.gitignore.hbs +6 -0
  26. package/templates/n8n/ai-assistant/Dockerfile.hbs +17 -0
  27. package/templates/n8n/ai-assistant/README.md.hbs +11 -0
  28. package/templates/n8n/ai-assistant/SETUP.md.hbs +60 -0
  29. package/templates/n8n/ai-assistant/docker-compose.yml.hbs +60 -0
  30. package/templates/n8n/ai-assistant/workflows/ai-assistant.json.hbs +32 -0
  31. package/templates/n8n/ai-assistant/workflows/auto-backup.json.hbs +43 -0
  32. package/templates/n8n/ai-assistant/workflows/global-error-handler.json.hbs +28 -0
  33. package/templates/n8n/crm-tracker/.env.example.hbs +14 -0
  34. package/templates/n8n/crm-tracker/.gitignore.hbs +6 -0
  35. package/templates/n8n/crm-tracker/Dockerfile.hbs +17 -0
  36. package/templates/n8n/crm-tracker/README.md.hbs +11 -0
  37. package/templates/n8n/crm-tracker/SETUP.md.hbs +59 -0
  38. package/templates/n8n/crm-tracker/docker-compose.yml.hbs +60 -0
  39. package/templates/n8n/crm-tracker/workflows/auto-backup.json.hbs +43 -0
  40. package/templates/n8n/crm-tracker/workflows/crm-enrichment.json.hbs +46 -0
  41. package/templates/n8n/crm-tracker/workflows/global-error-handler.json.hbs +28 -0
  42. package/templates/n8n/default/.env.example.hbs +14 -0
  43. package/templates/n8n/default/.gitignore.hbs +6 -0
  44. package/templates/n8n/default/Dockerfile.hbs +17 -0
  45. package/templates/n8n/default/README.md.hbs +51 -0
  46. package/templates/n8n/default/SETUP.md.hbs +52 -0
  47. package/templates/n8n/default/docker-compose.yml.hbs +60 -0
  48. package/templates/n8n/default/workflows/auto-backup.json.hbs +38 -0
  49. package/templates/n8n/default/workflows/global-error-handler.json.hbs +28 -0
  50. package/templates/n8n/system-monitor/.env.example.hbs +14 -0
  51. package/templates/n8n/system-monitor/.gitignore.hbs +6 -0
  52. package/templates/n8n/system-monitor/Dockerfile.hbs +17 -0
  53. package/templates/n8n/system-monitor/README.md.hbs +11 -0
  54. package/templates/n8n/system-monitor/SETUP.md.hbs +59 -0
  55. package/templates/n8n/system-monitor/docker-compose.yml.hbs +60 -0
  56. package/templates/n8n/system-monitor/workflows/auto-backup.json.hbs +43 -0
  57. package/templates/n8n/system-monitor/workflows/global-error-handler.json.hbs +28 -0
  58. package/templates/n8n/system-monitor/workflows/health-check.json.hbs +66 -0
  59. package/templates/nestjs/clean/src/app.module.ts.hbs +3 -1
  60. package/templates/nestjs/clean/src/application/payment.service.ts.hbs +90 -0
  61. package/templates/nestjs/clean/src/infrastructure/http/payment.controller.ts.hbs +46 -0
  62. package/templates/nestjs/clean/src/infrastructure/stripe.provider.ts.hbs +51 -0
  63. package/templates/nestjs/clean/src/main.ts.hbs +13 -4
  64. package/templates/nestjs/clean/src/payment.module.ts.hbs +23 -0
  65. package/templates/nestjs/hexagonal/src/adapters/inbound/payment.controller.ts.hbs +46 -0
  66. package/templates/nestjs/hexagonal/src/adapters/outbound/stripe.adapter.ts.hbs +54 -0
  67. package/templates/nestjs/hexagonal/src/app.module.ts.hbs +2 -0
  68. package/templates/nestjs/hexagonal/src/core/payment.service.ts.hbs +90 -0
  69. package/templates/nestjs/hexagonal/src/main.ts.hbs +13 -4
  70. package/templates/nestjs/hexagonal/src/payment.module.ts.hbs +23 -0
  71. package/templates/nestjs/mvc/src/main.ts.hbs +6 -3
  72. package/templates/nestjs/mvc/src/payments/payments.controller.ts.hbs +33 -8
  73. package/templates/nestjs/mvc/src/payments/payments.service.ts.hbs +66 -22
  74. package/templates/nextjs/mvc/src/app/api/checkout/route.ts.hbs +42 -13
  75. package/templates/nextjs/mvc/src/app/api/portal/route.ts.hbs +36 -0
  76. package/templates/nextjs/mvc/src/app/api/webhook/route.ts.hbs +32 -20
  77. package/templates/nodejs-express/clean/src/application/services/PaymentService.ts.hbs +98 -0
  78. package/templates/nodejs-express/clean/src/index.ts.hbs +29 -8
  79. package/templates/nodejs-express/clean/src/infrastructure/http/controllers/PaymentController.ts.hbs +57 -0
  80. package/templates/nodejs-express/clean/src/infrastructure/providers/StripeProvider.ts.hbs +45 -0
  81. package/templates/nodejs-express/hexagonal/src/adapters/inbound/http/PaymentController.ts.hbs +57 -0
  82. package/templates/nodejs-express/hexagonal/src/adapters/outbound/StripeAdapter.ts.hbs +48 -0
  83. package/templates/nodejs-express/hexagonal/src/core/PaymentService.ts.hbs +89 -0
  84. package/templates/nodejs-express/hexagonal/src/index.ts.hbs +28 -8
  85. package/templates/nodejs-express/mvc/src/app.ts.hbs +11 -2
  86. package/templates/nodejs-express/mvc/src/controllers/payments.controller.ts.hbs +31 -47
  87. package/templates/nodejs-express/mvc/src/services/stripe.service.ts.hbs +66 -49
  88. package/templates/python-fastapi/clean/app/application/services/payment_service.py.hbs +85 -0
  89. package/templates/python-fastapi/clean/app/infrastructure/http/payment_controller.py.hbs +64 -0
  90. package/templates/python-fastapi/clean/app/infrastructure/stripe_provider.py.hbs +44 -0
  91. package/templates/python-fastapi/clean/app/main.py.hbs +8 -5
  92. package/templates/python-fastapi/hexagonal/app/adapters/inbound/payment_http_adapter.py.hbs +64 -0
  93. package/templates/python-fastapi/hexagonal/app/adapters/outbound/stripe_adapter.py.hbs +44 -0
  94. package/templates/python-fastapi/hexagonal/app/core/payment_service.py.hbs +81 -0
  95. package/templates/python-fastapi/hexagonal/app/main.py.hbs +9 -3
  96. package/templates/python-fastapi/mvc/app/controllers/payments.py.hbs +70 -35
  97. package/templates/python-fastapi/mvc/app/services/stripe_service.py.hbs +58 -0
@@ -2,21 +2,39 @@ import Stripe from 'stripe';
2
2
  import { prisma } from '../config/database';
3
3
 
4
4
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
5
- apiVersion: '2024-12-18.acacia',
5
+ apiVersion: '2026-02-25.clover',
6
6
  });
7
7
 
8
8
  export interface CreateCheckoutSessionParams {
9
+ userId: string;
9
10
  priceId: string;
10
- customerId?: string;
11
11
  successUrl: string;
12
12
  cancelUrl: string;
13
13
  }
14
14
 
15
15
  export class StripeService {
16
16
  /**
17
- * Create a checkout session for subscription
17
+ * Create or retrieve a Stripe customer for the user, then create a checkout session.
18
18
  */
19
19
  async createCheckoutSession(params: CreateCheckoutSessionParams) {
20
+ const user = await prisma.user.findUnique({ where: { id: params.userId } });
21
+ if (!user) throw new Error('User not found');
22
+
23
+ let customerId = user.stripeCustomerId;
24
+
25
+ if (!customerId) {
26
+ const customer = await stripe.customers.create({
27
+ email: user.email,
28
+ name: user.name ?? undefined,
29
+ metadata: { userId: user.id },
30
+ });
31
+ customerId = customer.id;
32
+ await prisma.user.update({
33
+ where: { id: params.userId },
34
+ data: { stripeCustomerId: customerId },
35
+ });
36
+ }
37
+
20
38
  const session = await stripe.checkout.sessions.create({
21
39
  mode: 'subscription',
22
40
  payment_method_types: ['card'],
@@ -26,20 +44,24 @@ export class StripeService {
26
44
  quantity: 1,
27
45
  },
28
46
  ],
29
- customer: params.customerId,
47
+ customer: customerId,
30
48
  success_url: params.successUrl,
31
49
  cancel_url: params.cancelUrl,
50
+ client_reference_id: params.userId,
32
51
  });
33
52
 
34
53
  return session;
35
54
  }
36
55
 
37
56
  /**
38
- * Create a customer portal session
57
+ * Create a Stripe customer portal session so the user can manage their subscription.
39
58
  */
40
- async createPortalSession(customerId: string, returnUrl: string) {
59
+ async createPortalSession(userId: string, returnUrl: string) {
60
+ const user = await prisma.user.findUnique({ where: { id: userId } });
61
+ if (!user?.stripeCustomerId) throw new Error('Stripe customer not found for this user');
62
+
41
63
  const session = await stripe.billingPortal.sessions.create({
42
- customer: customerId,
64
+ customer: user.stripeCustomerId,
43
65
  return_url: returnUrl,
44
66
  });
45
67
 
@@ -47,66 +69,61 @@ export class StripeService {
47
69
  }
48
70
 
49
71
  /**
50
- * Handle webhook event
72
+ * Validate the Stripe webhook signature and handle events.
51
73
  */
52
74
  async handleWebhook(rawBody: Buffer, signature: string) {
53
75
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '';
54
76
 
55
- const event = stripe.webhooks.constructEvent(
56
- rawBody,
57
- signature,
58
- webhookSecret
59
- );
77
+ let event: Stripe.Event;
78
+ try {
79
+ event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
80
+ } catch (err: any) {
81
+ throw new Error(`Webhook signature verification failed: ${err.message}`);
82
+ }
60
83
 
61
84
  switch (event.type) {
62
- case 'checkout.session.completed':
63
- {
64
- const session = event.data.object as Stripe.Checkout.Session;
65
- // Handle successful subscription
66
- console.log('Checkout completed:', session.id);
67
- // Match to user and update DB, e.g., using session.client_reference_id
85
+ case 'checkout.session.completed': {
86
+ const session = event.data.object as Stripe.Checkout.Session;
87
+ const userId = session.client_reference_id;
88
+ if (userId && session.customer) {
89
+ await prisma.user.update({
90
+ where: { id: userId },
91
+ data: {
92
+ stripeCustomerId: session.customer as string,
93
+ },
94
+ });
68
95
  }
96
+ console.log('Checkout completed for user:', userId);
69
97
  break;
98
+ }
70
99
 
71
- case 'customer.subscription.updated':
72
- {
73
- const subscription = event.data.object as Stripe.Subscription;
74
- // Handle subscription update in DB
75
- console.log('Subscription updated:', subscription.id);
76
- }
100
+ case 'customer.subscription.updated': {
101
+ const subscription = event.data.object as Stripe.Subscription;
102
+ console.log('Subscription updated:', subscription.id, '| Status:', subscription.status);
103
+ // TODO: Update subscription status in DB (add subscriptionStatus field to User model)
77
104
  break;
105
+ }
78
106
 
79
- case 'customer.subscription.deleted':
80
- {
81
- const deletedSubscription = event.data.object as Stripe.Subscription;
82
- // Handle subscription cancellation in DB
83
- console.log('Subscription deleted:', deletedSubscription.id);
84
- }
107
+ case 'customer.subscription.deleted': {
108
+ const subscription = event.data.object as Stripe.Subscription;
109
+ console.log('Subscription deleted:', subscription.id);
110
+ // TODO: Mark user as unsubscribed in DB
85
111
  break;
112
+ }
86
113
 
87
- case 'invoice.payment_failed':
88
- {
89
- const invoice = event.data.object as Stripe.Invoice;
90
- // Handle failed payment
91
- console.log('Payment failed:', invoice.id);
92
- }
114
+ case 'invoice.payment_failed': {
115
+ const invoice = event.data.object as Stripe.Invoice;
116
+ console.log('Payment failed for invoice:', invoice.id);
117
+ // TODO: Notify user via email
93
118
  break;
119
+ }
120
+
121
+ default:
122
+ console.log('Unhandled Stripe event:', event.type);
94
123
  }
95
124
 
96
125
  return { received: true };
97
126
  }
98
-
99
- /**
100
- * Create a Stripe customer
101
- */
102
- async createCustomer(email: string, name?: string) {
103
- const customer = await stripe.customers.create({
104
- email,
105
- name,
106
- });
107
-
108
- return customer;
109
- }
110
127
  }
111
128
 
112
129
  export const stripeService = new StripeService();
@@ -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
  }