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,23 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '@nestjs/config';
|
|
3
|
+
import { PaymentController } from './adapters/inbound/payment.controller';
|
|
4
|
+
import { PaymentService } from './core/payment.service';
|
|
5
|
+
import { StripeAdapter } from './adapters/outbound/stripe.adapter';
|
|
6
|
+
import { UserPort } from './core/ports/user.port';
|
|
7
|
+
import { PrismaUserAdapter } from './adapters/outbound/persistence/prisma.user.adapter';
|
|
8
|
+
import { PrismaService } from './adapters/outbound/persistence/prisma.service';
|
|
9
|
+
|
|
10
|
+
@Module({
|
|
11
|
+
imports: [ConfigModule],
|
|
12
|
+
controllers: [PaymentController],
|
|
13
|
+
providers: [
|
|
14
|
+
PaymentService,
|
|
15
|
+
StripeAdapter,
|
|
16
|
+
PrismaService,
|
|
17
|
+
{
|
|
18
|
+
provide: UserPort,
|
|
19
|
+
useClass: PrismaUserAdapter,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
})
|
|
23
|
+
export class PaymentModule {}
|
|
@@ -3,7 +3,10 @@ import { AppModule } from './app.module';
|
|
|
3
3
|
import { ValidationPipe } from '@nestjs/common';
|
|
4
4
|
|
|
5
5
|
async function bootstrap() {
|
|
6
|
-
const app = await NestFactory.create(AppModule
|
|
6
|
+
const app = await NestFactory.create(AppModule, {
|
|
7
|
+
// rawBody is required so that the Stripe webhook can verify the signature
|
|
8
|
+
rawBody: true,
|
|
9
|
+
});
|
|
7
10
|
|
|
8
11
|
app.enableCors();
|
|
9
12
|
app.setGlobalPrefix('api');
|
|
@@ -12,6 +15,6 @@ async function bootstrap() {
|
|
|
12
15
|
const port = process.env.PORT || 3000;
|
|
13
16
|
await app.listen(port);
|
|
14
17
|
|
|
15
|
-
console.log(
|
|
18
|
+
console.log(`{{pascalCase projectName}} running on port ${port}`);
|
|
16
19
|
}
|
|
17
|
-
bootstrap();
|
|
20
|
+
bootstrap();
|
|
@@ -1,26 +1,51 @@
|
|
|
1
|
-
import { Controller, Post, Body, Headers, UseGuards,
|
|
1
|
+
import { Controller, Post, Body, Headers, UseGuards, Req, Request, BadRequestException } from '@nestjs/common';
|
|
2
2
|
import { AuthGuard } from '@nestjs/passport';
|
|
3
|
+
import { IsString } from 'class-validator';
|
|
3
4
|
import { PaymentsService } from './payments.service';
|
|
4
|
-
import { Request } from 'express';
|
|
5
5
|
|
|
6
6
|
class CreateCheckoutDto {
|
|
7
|
+
@IsString()
|
|
7
8
|
priceId: string;
|
|
8
|
-
customerId?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
@Controller('payments')
|
|
12
12
|
export class PaymentsController {
|
|
13
13
|
constructor(private paymentsService: PaymentsService) {}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* POST /api/payments/checkout
|
|
17
|
+
* Requires JWT authentication.
|
|
18
|
+
*/
|
|
15
19
|
@Post('checkout')
|
|
16
20
|
@UseGuards(AuthGuard('jwt'))
|
|
17
|
-
async createCheckout(@Body() dto: CreateCheckoutDto) {
|
|
18
|
-
const
|
|
21
|
+
async createCheckout(@Req() req: Request, @Body() dto: CreateCheckoutDto) {
|
|
22
|
+
const userId = (req as any).user?.id;
|
|
23
|
+
const session = await this.paymentsService.createCheckoutSession(userId, dto.priceId);
|
|
19
24
|
return { url: session.url };
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
/**
|
|
28
|
+
* POST /api/payments/portal
|
|
29
|
+
* Requires JWT authentication.
|
|
30
|
+
*/
|
|
31
|
+
@Post('portal')
|
|
32
|
+
@UseGuards(AuthGuard('jwt'))
|
|
33
|
+
async createPortal(@Req() req: Request) {
|
|
34
|
+
const userId = (req as any).user?.id;
|
|
35
|
+
const session = await this.paymentsService.createPortalSession(userId);
|
|
36
|
+
return { url: session.url };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* POST /api/payments/webhook
|
|
41
|
+
* No authentication. Stripe sends a raw body – NestFactory must have rawBody: true.
|
|
42
|
+
*/
|
|
22
43
|
@Post('webhook')
|
|
23
|
-
async webhook(
|
|
24
|
-
|
|
44
|
+
async webhook(
|
|
45
|
+
@Req() req: any,
|
|
46
|
+
@Headers('stripe-signature') signature: string,
|
|
47
|
+
) {
|
|
48
|
+
if (!signature) throw new BadRequestException('Missing stripe-signature header');
|
|
49
|
+
return this.paymentsService.handleWebhook(req.rawBody, signature);
|
|
25
50
|
}
|
|
26
|
-
}
|
|
51
|
+
}
|
|
@@ -12,50 +12,94 @@ export class PaymentsService {
|
|
|
12
12
|
private prisma: PrismaService,
|
|
13
13
|
) {
|
|
14
14
|
this.stripe = new Stripe(configService.get('STRIPE_SECRET_KEY', ''), {
|
|
15
|
-
apiVersion: '
|
|
15
|
+
apiVersion: '2026-02-25.clover',
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async createCheckoutSession(
|
|
19
|
+
async createCheckoutSession(userId: string, priceId: string) {
|
|
20
|
+
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
21
|
+
if (!user) throw new Error('User not found');
|
|
22
|
+
|
|
23
|
+
let customerId = user.stripeCustomerId;
|
|
24
|
+
|
|
25
|
+
if (!customerId) {
|
|
26
|
+
const customer = await this.stripe.customers.create({
|
|
27
|
+
email: user.email,
|
|
28
|
+
metadata: { userId },
|
|
29
|
+
});
|
|
30
|
+
customerId = customer.id;
|
|
31
|
+
await this.prisma.user.update({
|
|
32
|
+
where: { id: userId },
|
|
33
|
+
data: { stripeCustomerId: customerId },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
const session = await this.stripe.checkout.sessions.create({
|
|
21
38
|
mode: 'subscription',
|
|
22
39
|
payment_method_types: ['card'],
|
|
23
40
|
line_items: [{ price: priceId, quantity: 1 }],
|
|
24
|
-
customer: customerId
|
|
41
|
+
customer: customerId,
|
|
25
42
|
success_url: `${this.configService.get('FRONTEND_URL')}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
26
43
|
cancel_url: `${this.configService.get('FRONTEND_URL')}/cancel`,
|
|
44
|
+
client_reference_id: userId,
|
|
27
45
|
});
|
|
28
46
|
|
|
29
47
|
return session;
|
|
30
48
|
}
|
|
31
49
|
|
|
50
|
+
async createPortalSession(userId: string) {
|
|
51
|
+
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
52
|
+
if (!user?.stripeCustomerId) throw new Error('No Stripe customer found for this user');
|
|
53
|
+
|
|
54
|
+
return this.stripe.billingPortal.sessions.create({
|
|
55
|
+
customer: user.stripeCustomerId,
|
|
56
|
+
return_url: `${this.configService.get('FRONTEND_URL')}/dashboard`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
async handleWebhook(payload: Buffer, signature: string) {
|
|
33
61
|
const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET', '');
|
|
34
|
-
|
|
62
|
+
|
|
63
|
+
let event: Stripe.Event;
|
|
64
|
+
try {
|
|
65
|
+
event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
throw new Error(`Webhook signature verification failed: ${err.message}`);
|
|
68
|
+
}
|
|
35
69
|
|
|
36
70
|
switch (event.type) {
|
|
37
|
-
case 'checkout.session.completed':
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
71
|
+
case 'checkout.session.completed': {
|
|
72
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
73
|
+
const userId = session.client_reference_id;
|
|
74
|
+
if (userId && session.customer) {
|
|
75
|
+
await this.prisma.user.update({
|
|
76
|
+
where: { id: userId },
|
|
77
|
+
data: { stripeCustomerId: session.customer as string },
|
|
78
|
+
});
|
|
43
79
|
}
|
|
80
|
+
console.log('Checkout completed for user:', userId);
|
|
44
81
|
break;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
82
|
+
}
|
|
83
|
+
case 'customer.subscription.updated': {
|
|
84
|
+
const sub = event.data.object as Stripe.Subscription;
|
|
85
|
+
console.log('Subscription updated:', sub.id, '| Status:', sub.status);
|
|
86
|
+
// TODO: Update subscriptionStatus in DB
|
|
51
87
|
break;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
88
|
+
}
|
|
89
|
+
case 'customer.subscription.deleted': {
|
|
90
|
+
const sub = event.data.object as Stripe.Subscription;
|
|
91
|
+
console.log('Subscription deleted:', sub.id);
|
|
92
|
+
// TODO: Mark user as unsubscribed in DB
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case 'invoice.payment_failed': {
|
|
96
|
+
const invoice = event.data.object as Stripe.Invoice;
|
|
97
|
+
console.log('Payment failed for invoice:', invoice.id);
|
|
98
|
+
// TODO: Notify user via email
|
|
58
99
|
break;
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
console.log('Unhandled Stripe event:', event.type);
|
|
59
103
|
}
|
|
60
104
|
|
|
61
105
|
return { received: true };
|
|
@@ -1,30 +1,59 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import Stripe from "stripe";
|
|
3
|
+
import { getServerSession } from "next-auth";
|
|
4
|
+
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
|
5
|
+
import { prisma } from "@/lib/prisma";
|
|
3
6
|
|
|
4
7
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
|
5
|
-
apiVersion: "
|
|
8
|
+
apiVersion: "2026-02-25.clover",
|
|
6
9
|
});
|
|
7
10
|
|
|
8
11
|
export async function POST(request: NextRequest) {
|
|
9
12
|
try {
|
|
10
|
-
const
|
|
13
|
+
const session = await getServerSession(authOptions);
|
|
14
|
+
if (!session?.user?.email) {
|
|
15
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
16
|
+
}
|
|
11
17
|
|
|
12
|
-
const
|
|
18
|
+
const { priceId } = await request.json();
|
|
19
|
+
if (!priceId) {
|
|
20
|
+
return NextResponse.json({ error: "priceId is required" }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Get or create Stripe customer
|
|
24
|
+
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
|
25
|
+
if (!user) {
|
|
26
|
+
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let customerId = user.stripeCustomerId;
|
|
30
|
+
|
|
31
|
+
if (!customerId) {
|
|
32
|
+
const customer = await stripe.customers.create({
|
|
33
|
+
email: user.email,
|
|
34
|
+
name: user.name ?? undefined,
|
|
35
|
+
metadata: { userId: user.id },
|
|
36
|
+
});
|
|
37
|
+
customerId = customer.id;
|
|
38
|
+
await prisma.user.update({
|
|
39
|
+
where: { id: user.id },
|
|
40
|
+
data: { stripeCustomerId: customerId },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const checkoutSession = await stripe.checkout.sessions.create({
|
|
13
45
|
mode: "subscription",
|
|
14
46
|
payment_method_types: ["card"],
|
|
15
|
-
line_items: [
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
],
|
|
21
|
-
customer: customerId || undefined,
|
|
22
|
-
success_url: ,
|
|
23
|
-
cancel_url: ,
|
|
47
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
48
|
+
customer: customerId,
|
|
49
|
+
success_url: `${process.env.NEXTAUTH_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
50
|
+
cancel_url: `${process.env.NEXTAUTH_URL}/dashboard`,
|
|
51
|
+
client_reference_id: user.id,
|
|
24
52
|
});
|
|
25
53
|
|
|
26
|
-
return NextResponse.json({ url:
|
|
54
|
+
return NextResponse.json({ url: checkoutSession.url });
|
|
27
55
|
} catch (error: any) {
|
|
56
|
+
console.error("Checkout error:", error.message);
|
|
28
57
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
29
58
|
}
|
|
30
59
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import Stripe from "stripe";
|
|
3
|
+
import { getServerSession } from "next-auth";
|
|
4
|
+
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
|
5
|
+
import { prisma } from "@/lib/prisma";
|
|
6
|
+
|
|
7
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
|
8
|
+
apiVersion: "2026-02-25.clover",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export async function POST(request: NextRequest) {
|
|
12
|
+
try {
|
|
13
|
+
const session = await getServerSession(authOptions);
|
|
14
|
+
if (!session?.user?.email) {
|
|
15
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
|
19
|
+
if (!user?.stripeCustomerId) {
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: "No Stripe customer found for this user" },
|
|
22
|
+
{ status: 400 }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const portalSession = await stripe.billingPortal.sessions.create({
|
|
27
|
+
customer: user.stripeCustomerId,
|
|
28
|
+
return_url: `${process.env.NEXTAUTH_URL}/dashboard`,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return NextResponse.json({ url: portalSession.url });
|
|
32
|
+
} catch (error: any) {
|
|
33
|
+
console.error("Portal error:", error.message);
|
|
34
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -3,7 +3,7 @@ import Stripe from "stripe";
|
|
|
3
3
|
import { prisma } from "@/lib/prisma";
|
|
4
4
|
|
|
5
5
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
|
6
|
-
apiVersion: "
|
|
6
|
+
apiVersion: "2026-02-25.clover",
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || "";
|
|
@@ -12,44 +12,56 @@ export async function POST(request: NextRequest) {
|
|
|
12
12
|
const body = await request.text();
|
|
13
13
|
const signature = request.headers.get("stripe-signature") || "";
|
|
14
14
|
|
|
15
|
+
if (!signature) {
|
|
16
|
+
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
let event: Stripe.Event;
|
|
16
20
|
|
|
17
21
|
try {
|
|
18
22
|
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
|
19
23
|
} catch (err: any) {
|
|
20
|
-
console.error("Webhook signature verification failed
|
|
24
|
+
console.error("Webhook signature verification failed:", err.message);
|
|
21
25
|
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
switch (event.type) {
|
|
25
|
-
case "checkout.session.completed":
|
|
29
|
+
case "checkout.session.completed": {
|
|
26
30
|
const session = event.data.object as Stripe.Checkout.Session;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const userId = session.client_reference_id;
|
|
32
|
+
if (userId && session.customer) {
|
|
33
|
+
await prisma.user.update({
|
|
34
|
+
where: { id: userId },
|
|
35
|
+
data: { stripeCustomerId: session.customer as string },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
console.log("Checkout completed for user:", userId);
|
|
35
39
|
break;
|
|
40
|
+
}
|
|
36
41
|
|
|
37
|
-
case "customer.subscription.updated":
|
|
42
|
+
case "customer.subscription.updated": {
|
|
38
43
|
const subscription = event.data.object as Stripe.Subscription;
|
|
39
|
-
console.log("Subscription updated:", subscription.id);
|
|
40
|
-
// TODO:
|
|
44
|
+
console.log("Subscription updated:", subscription.id, "| Status:", subscription.status);
|
|
45
|
+
// TODO: update subscriptionStatus field on user
|
|
41
46
|
break;
|
|
47
|
+
}
|
|
42
48
|
|
|
43
|
-
case "customer.subscription.deleted":
|
|
44
|
-
const
|
|
45
|
-
console.log("Subscription deleted:",
|
|
46
|
-
// TODO:
|
|
49
|
+
case "customer.subscription.deleted": {
|
|
50
|
+
const subscription = event.data.object as Stripe.Subscription;
|
|
51
|
+
console.log("Subscription deleted:", subscription.id);
|
|
52
|
+
// TODO: mark user as unsubscribed
|
|
47
53
|
break;
|
|
54
|
+
}
|
|
48
55
|
|
|
49
|
-
case "invoice.payment_failed":
|
|
56
|
+
case "invoice.payment_failed": {
|
|
50
57
|
const invoice = event.data.object as Stripe.Invoice;
|
|
51
|
-
console.log("Payment failed:", invoice.id);
|
|
58
|
+
console.log("Payment failed for invoice:", invoice.id);
|
|
59
|
+
// TODO: notify user via email
|
|
52
60
|
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
default:
|
|
64
|
+
console.log("Unhandled Stripe event:", event.type);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
return NextResponse.json({ received: true });
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { IUserRepository } from '../../domain/repositories/IUserRepository';
|
|
2
|
+
import { StripeProvider } from '../../infrastructure/providers/StripeProvider';
|
|
3
|
+
|
|
4
|
+
export class PaymentService {
|
|
5
|
+
constructor(
|
|
6
|
+
private readonly userRepository: IUserRepository,
|
|
7
|
+
private readonly stripeProvider: StripeProvider,
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
async createCheckoutSession(userId: string, priceId: string) {
|
|
11
|
+
const user = await this.userRepository.findById(userId);
|
|
12
|
+
if (!user) throw new Error('User not found');
|
|
13
|
+
|
|
14
|
+
let customerId = user.stripeCustomerId;
|
|
15
|
+
|
|
16
|
+
if (!customerId) {
|
|
17
|
+
const customer = await this.stripeProvider.createCustomer(
|
|
18
|
+
user.email,
|
|
19
|
+
user.name,
|
|
20
|
+
user.id,
|
|
21
|
+
);
|
|
22
|
+
customerId = customer.id;
|
|
23
|
+
user.stripeCustomerId = customerId;
|
|
24
|
+
await this.userRepository.save(user);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const session = await this.stripeProvider.createCheckoutSession(
|
|
28
|
+
customerId,
|
|
29
|
+
priceId,
|
|
30
|
+
`${process.env.FRONTEND_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
31
|
+
`${process.env.FRONTEND_URL}/cancel`,
|
|
32
|
+
userId,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return session;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async createPortalSession(userId: string) {
|
|
39
|
+
const user = await this.userRepository.findById(userId);
|
|
40
|
+
if (!user?.stripeCustomerId) throw new Error('No Stripe customer found for this user');
|
|
41
|
+
|
|
42
|
+
return this.stripeProvider.createPortalSession(
|
|
43
|
+
user.stripeCustomerId,
|
|
44
|
+
`${process.env.FRONTEND_URL}/dashboard`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async handleWebhook(rawBody: Buffer, signature: string) {
|
|
49
|
+
let event: ReturnType<typeof this.stripeProvider.constructEvent>;
|
|
50
|
+
try {
|
|
51
|
+
event = this.stripeProvider.constructEvent(rawBody, signature);
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
throw new Error(`Webhook signature verification failed: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case 'checkout.session.completed': {
|
|
58
|
+
const session = event.data.object as any;
|
|
59
|
+
const userId = session.client_reference_id as string;
|
|
60
|
+
if (userId && session.customer) {
|
|
61
|
+
const user = await this.userRepository.findById(userId);
|
|
62
|
+
if (user) {
|
|
63
|
+
user.stripeCustomerId = session.customer as string;
|
|
64
|
+
await this.userRepository.save(user);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
console.log('Checkout completed for user:', userId);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'customer.subscription.updated': {
|
|
72
|
+
const subscription = event.data.object as any;
|
|
73
|
+
console.log('Subscription updated:', subscription.id, '| Status:', subscription.status);
|
|
74
|
+
// TODO: Update subscriptionStatus field in user model
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case 'customer.subscription.deleted': {
|
|
79
|
+
const subscription = event.data.object as any;
|
|
80
|
+
console.log('Subscription deleted:', subscription.id);
|
|
81
|
+
// TODO: Mark user as unsubscribed in DB
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
case 'invoice.payment_failed': {
|
|
86
|
+
const invoice = event.data.object as any;
|
|
87
|
+
console.log('Payment failed for invoice:', invoice.id);
|
|
88
|
+
// TODO: Notify user via email
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
default:
|
|
93
|
+
console.log('Unhandled Stripe event:', event.type);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { received: true };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -5,32 +5,53 @@ import helmet from 'helmet';
|
|
|
5
5
|
import morgan from 'morgan';
|
|
6
6
|
import { config } from './config';
|
|
7
7
|
import { PrismaUserRepository } from './infrastructure/database/PrismaUserRepository';
|
|
8
|
+
import { jwtTokenGenerator } from './infrastructure/providers/TokenGenerator';
|
|
9
|
+
import { PasswordHasher } from './infrastructure/providers/PasswordHasher';
|
|
10
|
+
import { StripeProvider } from './infrastructure/providers/StripeProvider';
|
|
8
11
|
import { AuthController } from './infrastructure/http/controllers/AuthController';
|
|
12
|
+
import { PaymentController } from './infrastructure/http/controllers/PaymentController';
|
|
13
|
+
import { authMiddleware } from './infrastructure/http/middlewares/authMiddleware';
|
|
14
|
+
import { PaymentService } from './application/services/PaymentService';
|
|
9
15
|
import { errorHandler } from './infrastructure/http/middlewares/errorHandler';
|
|
10
16
|
|
|
11
17
|
const app = express();
|
|
12
18
|
|
|
13
|
-
//
|
|
14
|
-
app.use(express.json());
|
|
15
|
-
app.use(cors());
|
|
19
|
+
// Security middleware
|
|
16
20
|
app.use(helmet());
|
|
21
|
+
app.use(cors());
|
|
17
22
|
app.use(morgan('dev'));
|
|
18
23
|
|
|
19
|
-
//
|
|
24
|
+
// === IMPORTANT: raw body for Stripe webhook must come BEFORE express.json() ===
|
|
25
|
+
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
|
|
26
|
+
|
|
27
|
+
// JSON parsing for all other routes
|
|
28
|
+
app.use(express.json());
|
|
29
|
+
|
|
30
|
+
// --- Dependency Injection ---
|
|
20
31
|
const userRepository = new PrismaUserRepository();
|
|
21
|
-
const
|
|
32
|
+
const stripeProvider = new StripeProvider();
|
|
22
33
|
|
|
23
|
-
|
|
34
|
+
const authController = new AuthController(userRepository, jwtTokenGenerator, new PasswordHasher());
|
|
35
|
+
const paymentService = new PaymentService(userRepository, stripeProvider);
|
|
36
|
+
const paymentController = new PaymentController(paymentService);
|
|
37
|
+
|
|
38
|
+
// --- Auth Routes ---
|
|
24
39
|
app.post('/api/auth/register', authController.register);
|
|
25
40
|
app.post('/api/auth/login', authController.login);
|
|
26
41
|
|
|
42
|
+
// --- Payment Routes ---
|
|
43
|
+
app.post('/api/payments/checkout', authMiddleware, paymentController.createCheckout);
|
|
44
|
+
app.post('/api/payments/portal', authMiddleware, paymentController.createPortal);
|
|
45
|
+
app.post('/api/payments/webhook', paymentController.handleWebhook);
|
|
46
|
+
|
|
47
|
+
// --- Health Check ---
|
|
27
48
|
app.get('/health', (req, res) => {
|
|
28
49
|
res.json({ status: 'ok', architecture: 'clean' });
|
|
29
50
|
});
|
|
30
51
|
|
|
31
|
-
// Error Handler
|
|
52
|
+
// --- Error Handler (must be last) ---
|
|
32
53
|
app.use(errorHandler);
|
|
33
54
|
|
|
34
55
|
app.listen(config.port, () => {
|
|
35
|
-
console.log(
|
|
56
|
+
console.log(`Server running on port ${config.port}`);
|
|
36
57
|
});
|
package/templates/nodejs-express/clean/src/infrastructure/http/controllers/PaymentController.ts.hbs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { PaymentService } from '../../../application/services/PaymentService';
|
|
3
|
+
|
|
4
|
+
export class PaymentController {
|
|
5
|
+
constructor(private readonly paymentService: PaymentService) {}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/payments/checkout
|
|
9
|
+
* Requires authentication (attach authMiddleware in index.ts)
|
|
10
|
+
*/
|
|
11
|
+
createCheckout = async (req: Request, res: Response, next: NextFunction) => {
|
|
12
|
+
try {
|
|
13
|
+
const userId = (req as any).userId as string;
|
|
14
|
+
const { priceId } = req.body;
|
|
15
|
+
|
|
16
|
+
if (!priceId) {
|
|
17
|
+
return res.status(400).json({ error: 'priceId is required' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const session = await this.paymentService.createCheckoutSession(userId, priceId);
|
|
21
|
+
res.json({ url: session.url });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
next(err);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POST /api/payments/portal
|
|
29
|
+
* Requires authentication (attach authMiddleware in index.ts)
|
|
30
|
+
*/
|
|
31
|
+
createPortal = async (req: Request, res: Response, next: NextFunction) => {
|
|
32
|
+
try {
|
|
33
|
+
const userId = (req as any).userId as string;
|
|
34
|
+
const session = await this.paymentService.createPortalSession(userId);
|
|
35
|
+
res.json({ url: session.url });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
next(err);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* POST /api/payments/webhook
|
|
43
|
+
* No authentication – Stripe sends the raw body here.
|
|
44
|
+
*/
|
|
45
|
+
handleWebhook = async (req: Request, res: Response, next: NextFunction) => {
|
|
46
|
+
try {
|
|
47
|
+
const signature = req.headers['stripe-signature'] as string;
|
|
48
|
+
if (!signature) {
|
|
49
|
+
return res.status(400).json({ error: 'Missing stripe-signature header' });
|
|
50
|
+
}
|
|
51
|
+
const result = await this.paymentService.handleWebhook(req.body, signature);
|
|
52
|
+
res.json(result);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
next(err);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|