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.
- package/dist/cli/prompts/wizard.d.ts.map +1 -1
- package/dist/cli/prompts/wizard.js +22 -4
- package/dist/cli/prompts/wizard.js.map +1 -1
- package/dist/core/ai/prompts/documentation-prompts.d.ts.map +1 -1
- package/dist/core/ai/prompts/documentation-prompts.js +6 -0
- package/dist/core/ai/prompts/documentation-prompts.js.map +1 -1
- package/dist/core/generator/project.d.ts.map +1 -1
- package/dist/core/generator/project.js +6 -1
- package/dist/core/generator/project.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/models/config.d.ts +1 -1
- package/dist/models/config.d.ts.map +1 -1
- 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/n8n/ai-assistant/.env.example.hbs +14 -0
- package/templates/n8n/ai-assistant/.gitignore.hbs +6 -0
- package/templates/n8n/ai-assistant/Dockerfile.hbs +17 -0
- package/templates/n8n/ai-assistant/README.md.hbs +11 -0
- package/templates/n8n/ai-assistant/SETUP.md.hbs +60 -0
- package/templates/n8n/ai-assistant/docker-compose.yml.hbs +60 -0
- package/templates/n8n/ai-assistant/workflows/ai-assistant.json.hbs +32 -0
- package/templates/n8n/ai-assistant/workflows/auto-backup.json.hbs +43 -0
- package/templates/n8n/ai-assistant/workflows/global-error-handler.json.hbs +28 -0
- package/templates/n8n/crm-tracker/.env.example.hbs +14 -0
- package/templates/n8n/crm-tracker/.gitignore.hbs +6 -0
- package/templates/n8n/crm-tracker/Dockerfile.hbs +17 -0
- package/templates/n8n/crm-tracker/README.md.hbs +11 -0
- package/templates/n8n/crm-tracker/SETUP.md.hbs +59 -0
- package/templates/n8n/crm-tracker/docker-compose.yml.hbs +60 -0
- package/templates/n8n/crm-tracker/workflows/auto-backup.json.hbs +43 -0
- package/templates/n8n/crm-tracker/workflows/crm-enrichment.json.hbs +46 -0
- package/templates/n8n/crm-tracker/workflows/global-error-handler.json.hbs +28 -0
- package/templates/n8n/default/.env.example.hbs +14 -0
- package/templates/n8n/default/.gitignore.hbs +6 -0
- package/templates/n8n/default/Dockerfile.hbs +17 -0
- package/templates/n8n/default/README.md.hbs +51 -0
- package/templates/n8n/default/SETUP.md.hbs +52 -0
- package/templates/n8n/default/docker-compose.yml.hbs +60 -0
- package/templates/n8n/default/workflows/auto-backup.json.hbs +38 -0
- package/templates/n8n/default/workflows/global-error-handler.json.hbs +28 -0
- package/templates/n8n/system-monitor/.env.example.hbs +14 -0
- package/templates/n8n/system-monitor/.gitignore.hbs +6 -0
- package/templates/n8n/system-monitor/Dockerfile.hbs +17 -0
- package/templates/n8n/system-monitor/README.md.hbs +11 -0
- package/templates/n8n/system-monitor/SETUP.md.hbs +59 -0
- package/templates/n8n/system-monitor/docker-compose.yml.hbs +60 -0
- package/templates/n8n/system-monitor/workflows/auto-backup.json.hbs +43 -0
- package/templates/n8n/system-monitor/workflows/global-error-handler.json.hbs +28 -0
- package/templates/n8n/system-monitor/workflows/health-check.json.hbs +66 -0
- 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
|
@@ -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: '
|
|
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
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
signature,
|
|
58
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
}
|