CognxSafeTrack commited on
Commit
b5f6b08
·
1 Parent(s): d8249dc

feat(payment): integrate stripe checkout, webhooks, and worker dispatch

Browse files
apps/api/package.json CHANGED
@@ -17,6 +17,7 @@
17
  "openai": "^4.0.0",
18
  "pptxgenjs": "^3.12.0",
19
  "puppeteer": "^22.0.0",
 
20
  "zod": "^3.25.76"
21
  },
22
  "devDependencies": {
 
17
  "openai": "^4.0.0",
18
  "pptxgenjs": "^3.12.0",
19
  "puppeteer": "^22.0.0",
20
+ "stripe": "^20.3.1",
21
  "zod": "^3.25.76"
22
  },
23
  "devDependencies": {
apps/api/src/routes/payments.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FastifyInstance } from 'fastify';
2
+ import { stripeService } from '../services/stripe';
3
+
4
+ // Use the local PrismaClient to avoid the module resolution issue. We'll instantiate it here.
5
+ import { PrismaClient } from '@prisma/client';
6
+ const prisma = new PrismaClient();
7
+
8
+ export async function paymentRoutes(fastify: FastifyInstance) {
9
+
10
+ // Create a Checkout Session
11
+ fastify.post('/checkout', async (request, reply) => {
12
+ const { userId, trackId } = request.body as { userId: string, trackId: string };
13
+
14
+ if (!userId || !trackId) {
15
+ return reply.status(400).send({ error: 'Missing userId or trackId' });
16
+ }
17
+
18
+ try {
19
+ // Validate the track exists and is premium
20
+ const track = await prisma.track.findUnique({ where: { id: trackId } });
21
+
22
+ if (!track || !track.isPremium || !track.stripePriceId) {
23
+ return reply.status(400).send({ error: 'Invalid or non-premium track' });
24
+ }
25
+
26
+ const user = await prisma.user.findUnique({ where: { id: userId } });
27
+ if (!user) {
28
+ return reply.status(404).send({ error: 'User not found' });
29
+ }
30
+
31
+ const checkoutUrl = await stripeService.createCheckoutSession(
32
+ user.id,
33
+ track.id,
34
+ track.stripePriceId,
35
+ user.phone
36
+ );
37
+
38
+ return { success: true, url: checkoutUrl };
39
+
40
+ } catch (error) {
41
+ fastify.log.error(error);
42
+ return reply.status(500).send({ error: 'Failed to create checkout session' });
43
+ }
44
+ });
45
+
46
+ // Handle Stripe Webhook
47
+ // Note: We need the raw body to verify the signature. Fastify requires specific config for this.
48
+ fastify.post('/webhook', { config: { rawBody: true } }, async (request, reply) => {
49
+ const sig = request.headers['stripe-signature'];
50
+
51
+ if (!sig || typeof sig !== 'string') {
52
+ return reply.status(400).send({ error: 'Missing stripe-signature header' });
53
+ }
54
+
55
+ let event;
56
+
57
+ try {
58
+ // @ts-ignore - Assuming rawBody plugin is configured on the Fastify instance
59
+ event = stripeService.verifyWebhookSignature(request.rawBody || request.body, sig);
60
+ } catch (err: any) {
61
+ return reply.status(400).send(`Webhook Error: ${err.message}`);
62
+ }
63
+
64
+ // Handle the checkout.session.completed event
65
+ if (event.type === 'checkout.session.completed') {
66
+ const session = event.data.object as any;
67
+
68
+ const userId = session.metadata?.userId;
69
+ const trackId = session.metadata?.trackId;
70
+ const amountTotal = session.amount_total;
71
+
72
+ if (userId && trackId) {
73
+ try {
74
+ // Start a transaction: Record payment and Create Enrollment
75
+ await prisma.$transaction(async (tx: any) => {
76
+ // 1. Record the payment
77
+ await tx.payment.create({
78
+ data: {
79
+ userId,
80
+ trackId,
81
+ amount: amountTotal,
82
+ status: 'COMPLETED',
83
+ stripeSessionId: session.id,
84
+ currency: session.currency || 'XOF'
85
+ }
86
+ });
87
+
88
+ // 2. Create the Enrollment
89
+ // Check if an enrollment already exists to avoid duplicates
90
+ const existingEnrollment = await tx.enrollment.findFirst({
91
+ where: { userId, trackId }
92
+ });
93
+
94
+ if (!existingEnrollment) {
95
+ await tx.enrollment.create({
96
+ data: {
97
+ userId,
98
+ trackId,
99
+ status: 'ACTIVE',
100
+ currentDay: 1
101
+ }
102
+ });
103
+ fastify.log.info(`[PaymentRoute] Enrollment created for User ${userId}, Track ${trackId}`);
104
+ }
105
+ });
106
+ } catch (dbError) {
107
+ fastify.log.error(dbError, '[PaymentRoute] Database error during webhook processing');
108
+ // Standard practice is to still return a 200 to Stripe so it doesn't retry infinitely,
109
+ // but you'd want robust alerting here.
110
+ }
111
+ }
112
+ }
113
+
114
+ // Return a 200 response to acknowledge receipt of the event
115
+ reply.send({ received: true });
116
+ });
117
+ }
apps/api/src/services/stripe.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Stripe from 'stripe';
2
+
3
+ export class StripeService {
4
+ private stripe: Stripe;
5
+ private webhookSecret: string;
6
+ private clientUrl: string;
7
+
8
+ constructor() {
9
+ // Initialize Stripe. Defaults to a dummy key if env var is missing during dev.
10
+ const secretKey = process.env.STRIPE_SECRET_KEY || 'sk_test_dummy';
11
+ this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_dummy';
12
+ this.clientUrl = process.env.VITE_CLIENT_URL || 'http://localhost:5174';
13
+
14
+ this.stripe = new Stripe(secretKey, {
15
+ apiVersion: '2025-01-27.acacia' as any,
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Creates a Stripe Checkout Session for a specific track and user.
21
+ */
22
+ async createCheckoutSession(userId: string, trackId: string, priceId: string, userPhone: string) {
23
+ try {
24
+ const session = await this.stripe.checkout.sessions.create({
25
+ payment_method_types: ['card'],
26
+ line_items: [
27
+ {
28
+ price: priceId,
29
+ quantity: 1,
30
+ },
31
+ ],
32
+ mode: 'payment',
33
+ success_url: `${this.clientUrl}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
34
+ cancel_url: `${this.clientUrl}/payment-cancel`,
35
+ client_reference_id: userId,
36
+ metadata: {
37
+ userId,
38
+ trackId,
39
+ userPhone
40
+ }
41
+ });
42
+
43
+ return session.url;
44
+ } catch (error) {
45
+ console.error('[StripeService] Error creating checkout session:', error);
46
+ throw new Error('Failed to create payment session');
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Verifies the signature of an incoming Stripe webhook.
52
+ */
53
+ verifyWebhookSignature(payload: string | Buffer, signature: string): Stripe.Event {
54
+ try {
55
+ return this.stripe.webhooks.constructEvent(
56
+ payload,
57
+ signature,
58
+ this.webhookSecret
59
+ );
60
+ } catch (error) {
61
+ console.error('[StripeService] Webhook signature verification failed:', error);
62
+ throw error;
63
+ }
64
+ }
65
+ }
66
+
67
+ export const stripeService = new StripeService();
apps/whatsapp-worker/src/index.ts CHANGED
@@ -20,6 +20,50 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
20
  // TODO: Call WhatsApp Cloud API to send text
21
  console.log(`[MOCK SEND] To User ${userId}: "${text}"`);
22
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  else if (job.name === 'send-content') {
24
  const { userId, trackId, dayNumber } = job.data;
25
 
 
20
  // TODO: Call WhatsApp Cloud API to send text
21
  console.log(`[MOCK SEND] To User ${userId}: "${text}"`);
22
  }
23
+ else if (job.name === 'enroll-user') {
24
+ const { userId, trackId } = job.data;
25
+
26
+ const track = await prisma.track.findUnique({ where: { id: trackId } });
27
+ if (!track) {
28
+ console.error(`[WORKER] Enrollment failed: Track ${trackId} not found.`);
29
+ return;
30
+ }
31
+
32
+ if (track.isPremium) {
33
+ console.log(`[WORKER] User ${userId} requested Premium Track ${trackId}. Generating Payment Link...`);
34
+ try {
35
+ const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
36
+ const checkoutRes = await fetch(`${API_URL}/v1/payments/checkout`, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ userId, trackId })
40
+ });
41
+
42
+ const checkoutData = await checkoutRes.json();
43
+ if (checkoutRes.ok && checkoutData.url) {
44
+ console.log(`[MOCK SEND] To User ${userId}: "This track is Premium. Please complete your payment here: ${checkoutData.url}"`);
45
+ } else {
46
+ console.error('[WORKER] Failed to get checkout URL', checkoutData);
47
+ }
48
+ } catch (err) {
49
+ console.error('[WORKER] Error calling checkout endpoint', err);
50
+ }
51
+ } else {
52
+ console.log(`[WORKER] Enrolling User ${userId} in Free Track ${trackId}...`);
53
+ const existing = await prisma.enrollment.findFirst({ where: { userId, trackId } });
54
+ if (!existing) {
55
+ await prisma.enrollment.create({
56
+ data: {
57
+ userId,
58
+ trackId,
59
+ status: 'ACTIVE',
60
+ currentDay: 1
61
+ }
62
+ });
63
+ console.log(`[MOCK SEND] To User ${userId}: "Welcome to ${track.title}! Day 1 starts now."`);
64
+ }
65
+ }
66
+ }
67
  else if (job.name === 'send-content') {
68
  const { userId, trackId, dayNumber } = job.data;
69
 
packages/database/prisma/schema.prisma CHANGED
@@ -21,19 +21,27 @@ model User {
21
  enrollments Enrollment[]
22
  responses Response[]
23
  messages Message[]
 
24
  }
25
 
26
  model Track {
27
- id String @id @default(uuid())
28
- title String
29
- description String?
30
- duration Int // Duration in days
31
- language Language @default(FR)
32
- createdAt DateTime @default(now())
33
- updatedAt DateTime @updatedAt
34
-
35
- days TrackDay[]
36
- enrollments Enrollment[]
 
 
 
 
 
 
 
37
  }
38
 
39
  model TrackDay {
@@ -116,3 +124,24 @@ enum Direction {
116
  OUTBOUND
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  enrollments Enrollment[]
22
  responses Response[]
23
  messages Message[]
24
+ payments Payment[]
25
  }
26
 
27
  model Track {
28
+ id String @id @default(uuid())
29
+ title String
30
+ description String?
31
+ duration Int // Duration in days
32
+ language Language @default(FR)
33
+
34
+ // Payment Integration Fields
35
+ isPremium Boolean @default(false)
36
+ priceAmount Int? // Price in smallest currency unit (e.g., cents/XOF)
37
+ stripePriceId String? // Stripe Price ID
38
+
39
+ createdAt DateTime @default(now())
40
+ updatedAt DateTime @updatedAt
41
+
42
+ days TrackDay[]
43
+ enrollments Enrollment[]
44
+ payments Payment[]
45
  }
46
 
47
  model TrackDay {
 
124
  OUTBOUND
125
  }
126
 
127
+ model Payment {
128
+ id String @id @default(uuid())
129
+ userId String
130
+ trackId String
131
+ amount Int
132
+ currency String @default("XOF")
133
+ status PaymentStatus @default(PENDING)
134
+ stripeSessionId String? @unique
135
+ createdAt DateTime @default(now())
136
+ updatedAt DateTime @updatedAt
137
+
138
+ user User @relation(fields: [userId], references: [id])
139
+ track Track @relation(fields: [trackId], references: [id])
140
+ }
141
+
142
+ enum PaymentStatus {
143
+ PENDING
144
+ COMPLETED
145
+ FAILED
146
+ REFUNDED
147
+ }
pnpm-lock.yaml CHANGED
@@ -93,6 +93,9 @@ importers:
93
  puppeteer:
94
  specifier: ^22.0.0
95
  version: 22.15.0(typescript@5.9.3)
 
 
 
96
  zod:
97
  specifier: ^3.25.76
98
  version: 3.25.76
@@ -1982,6 +1985,15 @@ packages:
1982
  resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
1983
  engines: {node: '>=8'}
1984
 
 
 
 
 
 
 
 
 
 
1985
  sucrase@3.35.1:
1986
  resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
1987
  engines: {node: '>=16 || 14 >=14.17'}
@@ -3902,6 +3914,10 @@ snapshots:
3902
  dependencies:
3903
  ansi-regex: 5.0.1
3904
 
 
 
 
 
3905
  sucrase@3.35.1:
3906
  dependencies:
3907
  '@jridgewell/gen-mapping': 0.3.13
 
93
  puppeteer:
94
  specifier: ^22.0.0
95
  version: 22.15.0(typescript@5.9.3)
96
+ stripe:
97
+ specifier: ^20.3.1
98
+ version: 20.3.1(@types/node@20.19.33)
99
  zod:
100
  specifier: ^3.25.76
101
  version: 3.25.76
 
1985
  resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
1986
  engines: {node: '>=8'}
1987
 
1988
+ stripe@20.3.1:
1989
+ resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==}
1990
+ engines: {node: '>=16'}
1991
+ peerDependencies:
1992
+ '@types/node': '>=16'
1993
+ peerDependenciesMeta:
1994
+ '@types/node':
1995
+ optional: true
1996
+
1997
  sucrase@3.35.1:
1998
  resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
1999
  engines: {node: '>=16 || 14 >=14.17'}
 
3914
  dependencies:
3915
  ansi-regex: 5.0.1
3916
 
3917
+ stripe@20.3.1(@types/node@20.19.33):
3918
+ optionalDependencies:
3919
+ '@types/node': 20.19.33
3920
+
3921
  sucrase@3.35.1:
3922
  dependencies:
3923
  '@jridgewell/gen-mapping': 0.3.13