Skip to content

Commit 0f74163

Browse files
committed
feat: complete payment flow, verify signature
1 parent 914ae01 commit 0f74163

File tree

6 files changed

+498
-49
lines changed

6 files changed

+498
-49
lines changed

apps/api/prisma/schema.prisma

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ model QueryCount {
1313
}
1414

1515
model User {
16-
id String @id @default(cuid())
17-
email String @unique
16+
id String @id @default(cuid())
17+
email String @unique
1818
firstName String
1919
authMethod String
20-
createdAt DateTime @default(now())
21-
lastLogin DateTime @updatedAt
20+
createdAt DateTime @default(now())
21+
lastLogin DateTime @updatedAt
2222
accounts Account[]
2323
payments Payment[]
2424
subscriptions Subscription[]
@@ -53,8 +53,8 @@ model Payment {
5353
status String
5454
createdAt DateTime @default(now())
5555
updatedAt DateTime @updatedAt
56-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5756
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
57+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5858
}
5959

6060
model Subscription {
@@ -65,21 +65,20 @@ model Subscription {
6565
startDate DateTime @default(now())
6666
endDate DateTime
6767
autoRenew Boolean @default(true)
68-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
69-
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
7068
payments Payment[]
69+
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
70+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
7171
7272
@@index([userId])
7373
}
7474

7575
model Plan {
76-
id String @id @default(cuid())
77-
name String // e.g. "pro-monthly", "pro-yearly", "free"
78-
interval String // "monthly" | "yearly"
79-
price Int // amount in paise
80-
currency String @default("INR")
81-
createdAt DateTime @default(now())
82-
updatedAt DateTime @updatedAt
83-
76+
id String @id @default(cuid())
77+
name String
78+
interval String
79+
price Int
80+
currency String @default("INR")
81+
createdAt DateTime @default(now())
82+
updatedAt DateTime @updatedAt
8483
subscriptions Subscription[]
8584
}

apps/api/src/index.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import rateLimit from "express-rate-limit";
1111
import helmet from "helmet";
1212
import ipBlocker from "./middleware/ipBlock.js";
1313
import Razorpay from "razorpay";
14+
import crypto from "crypto";
15+
import { paymentService } from "./services/payment.service.js";
1416

1517
dotenv.config();
1618

@@ -58,7 +60,8 @@ const apiLimiter = rateLimit({
5860
legacyHeaders: false,
5961
});
6062

61-
// Request size limits
63+
// Request size limits (except for webhook - needs raw body)
64+
app.use("/webhook/razorpay", express.raw({ type: "application/json" }));
6265
app.use(express.json({ limit: "10kb" }));
6366
app.use(express.urlencoded({ limit: "10kb", extended: true }));
6467

@@ -95,6 +98,105 @@ app.get("/test", apiLimiter, (req: Request, res: Response) => {
9598
res.status(200).json({ status: "ok", message: "Test endpoint is working" });
9699
});
97100

101+
// Razorpay Webhook Handler (Backup Flow)
102+
app.post("/webhook/razorpay", async (req: Request, res: Response) => {
103+
try {
104+
const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET;
105+
if (!webhookSecret) {
106+
console.error("RAZORPAY_WEBHOOK_SECRET not configured");
107+
return res.status(500).json({ error: "Webhook not configured" });
108+
}
109+
110+
// Get signature from headers
111+
const signature = req.headers["x-razorpay-signature"] as string;
112+
if (!signature) {
113+
return res.status(400).json({ error: "Missing signature" });
114+
}
115+
116+
// Verify webhook signature
117+
const body = req.body.toString();
118+
const expectedSignature = crypto
119+
.createHmac("sha256", webhookSecret)
120+
.update(body)
121+
.digest("hex");
122+
123+
const isValidSignature = crypto.timingSafeEqual(
124+
Buffer.from(signature),
125+
Buffer.from(expectedSignature)
126+
);
127+
128+
if (!isValidSignature) {
129+
console.error("Invalid webhook signature");
130+
return res.status(400).json({ error: "Invalid signature" });
131+
}
132+
133+
// Parse the event
134+
const event = JSON.parse(body);
135+
const eventType = event.event;
136+
137+
// Handle payment.captured event
138+
if (eventType === "payment.captured") {
139+
const payment = event.payload.payment.entity;
140+
141+
// Extract payment details
142+
const razorpayPaymentId = payment.id;
143+
const razorpayOrderId = payment.order_id;
144+
const amount = payment.amount;
145+
const currency = payment.currency;
146+
147+
// Get user ID from order notes (should be stored when creating order)
148+
const notes = payment.notes || {};
149+
const userId = notes.user_id;
150+
151+
if (!userId) {
152+
console.error("User ID not found in payment notes");
153+
return res.status(400).json({ error: "User ID not found" });
154+
}
155+
156+
// Get plan ID from notes
157+
const planId = notes.plan_id;
158+
if (!planId) {
159+
console.error("Plan ID not found in payment notes");
160+
return res.status(400).json({ error: "Plan ID not found" });
161+
}
162+
163+
try {
164+
// Create payment record (with idempotency check)
165+
const paymentRecord = await paymentService.createPaymentRecord(userId, {
166+
razorpayPaymentId,
167+
razorpayOrderId,
168+
amount,
169+
currency,
170+
});
171+
172+
// Create subscription (with idempotency check)
173+
await paymentService.createSubscription(
174+
userId,
175+
planId,
176+
paymentRecord.id
177+
);
178+
179+
console.log(
180+
`✅ Webhook: Payment ${razorpayPaymentId} processed successfully`
181+
);
182+
return res.status(200).json({ status: "ok" });
183+
} catch (error: any) {
184+
console.error("Webhook payment processing error:", error);
185+
// Return 200 to prevent Razorpay retries for application errors
186+
return res
187+
.status(200)
188+
.json({ status: "ok", note: "Already processed" });
189+
}
190+
}
191+
192+
// Acknowledge other events
193+
return res.status(200).json({ status: "ok" });
194+
} catch (error: any) {
195+
console.error("Webhook error:", error);
196+
return res.status(500).json({ error: "Internal server error" });
197+
}
198+
});
199+
98200
// Connect to database
99201
prismaModule.connectDB();
100202

apps/api/src/routers/payment.ts

Lines changed: 152 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,69 @@ import { TRPCError } from "@trpc/server";
44
import { paymentService } from "../services/payment.service.js";
55

66
const createOrderSchema = z.object({
7-
amount: z.number().positive("Amount must be positive"),
8-
currency: z
9-
.string()
10-
.min(3, "Currency must be at least 3 characters")
11-
.max(3, "Currency must be 3 characters"),
7+
planId: z.string().min(1, "Plan ID is required"),
128
receipt: z.string().min(1, "Receipt is required"),
139
notes: z.record(z.string(), z.string()).optional(),
1410
});
1511

12+
const verifyPaymentSchema = z.object({
13+
razorpay_payment_id: z.string().min(1, "Payment ID is required"),
14+
razorpay_order_id: z.string().min(1, "Order ID is required"),
15+
razorpay_signature: z.string().min(1, "Signature is required"),
16+
planId: z.string().min(1, "Plan ID is required"),
17+
});
18+
1619
export const paymentRouter = router({
1720
createOrder: protectedProcedure
1821
.input(createOrderSchema)
1922
.mutation(
20-
async ({ input }: { input: z.infer<typeof createOrderSchema> }) => {
23+
async ({
24+
input,
25+
ctx,
26+
}: {
27+
input: z.infer<typeof createOrderSchema>;
28+
ctx: any;
29+
}) => {
2130
try {
31+
const userId = ctx.user?.id;
32+
if (!userId) {
33+
throw new TRPCError({
34+
code: "UNAUTHORIZED",
35+
message: "User not authenticated",
36+
});
37+
}
38+
39+
// Fetch plan from database to get the price
40+
const plan = await paymentService.getPlan(input.planId);
41+
42+
if (!plan) {
43+
throw new TRPCError({
44+
code: "NOT_FOUND",
45+
message: "Plan not found",
46+
});
47+
}
48+
49+
// Validate plan has required fields
50+
if (!plan.price || !plan.currency) {
51+
console.error("Plan missing required fields:", plan);
52+
throw new TRPCError({
53+
code: "BAD_REQUEST",
54+
message: "Plan is missing price or currency information",
55+
});
56+
}
57+
58+
// Add user_id and plan_id to notes for webhook processing
59+
const notesWithUserId = {
60+
...(input.notes || {}),
61+
user_id: userId,
62+
plan_id: input.planId,
63+
};
64+
2265
const result = await paymentService.createOrder({
23-
amount: input.amount,
24-
currency: input.currency,
66+
amount: plan.price, // Use price from database
67+
currency: plan.currency,
2568
receipt: input.receipt,
26-
...(input.notes && { notes: input.notes }),
69+
notes: notesWithUserId,
2770
});
2871

2972
// Check if it's an error response
@@ -52,4 +95,104 @@ export const paymentRouter = router({
5295
}
5396
}
5497
),
98+
99+
verifyPayment: protectedProcedure
100+
.input(verifyPaymentSchema)
101+
.mutation(
102+
async ({
103+
input,
104+
ctx,
105+
}: {
106+
input: z.infer<typeof verifyPaymentSchema>;
107+
ctx: any;
108+
}) => {
109+
try {
110+
const userId = ctx.user?.id;
111+
if (!userId) {
112+
throw new TRPCError({
113+
code: "UNAUTHORIZED",
114+
message: "User not authenticated",
115+
});
116+
}
117+
118+
// Fetch plan from database to get amount and currency
119+
const plan = await paymentService.getPlan(input.planId);
120+
121+
if (!plan) {
122+
throw new TRPCError({
123+
code: "NOT_FOUND",
124+
message: "Plan not found",
125+
});
126+
}
127+
128+
// Validate plan has required fields
129+
if (!plan.price || !plan.currency) {
130+
console.error("Plan missing required fields:", plan);
131+
throw new TRPCError({
132+
code: "BAD_REQUEST",
133+
message: "Plan is missing price or currency information",
134+
});
135+
}
136+
137+
// Step 1: Verify signature first (fail fast if invalid)
138+
const isValidSignature = paymentService.verifyPaymentSignature(
139+
input.razorpay_order_id,
140+
input.razorpay_payment_id,
141+
input.razorpay_signature
142+
);
143+
if (!isValidSignature) {
144+
throw new TRPCError({
145+
code: "BAD_REQUEST",
146+
message: "Invalid payment signature",
147+
});
148+
}
149+
150+
// Step 2: Create payment record (with idempotency check)
151+
const payment = await paymentService.createPaymentRecord(userId, {
152+
razorpayPaymentId: input.razorpay_payment_id,
153+
razorpayOrderId: input.razorpay_order_id,
154+
amount: plan.price, // Use price from database
155+
currency: plan.currency,
156+
});
157+
158+
// Step 3: Create/activate subscription
159+
const subscription = await paymentService.createSubscription(
160+
userId,
161+
input.planId,
162+
payment.id
163+
);
164+
165+
return {
166+
success: true,
167+
message: "Payment verified and subscription activated",
168+
payment: {
169+
id: payment.id,
170+
razorpayPaymentId: payment.razorpayPaymentId,
171+
amount: payment.amount,
172+
currency: payment.currency,
173+
status: payment.status,
174+
},
175+
subscription: {
176+
id: subscription.id,
177+
status: subscription.status,
178+
startDate: subscription.startDate,
179+
endDate: subscription.endDate,
180+
},
181+
};
182+
} catch (error: any) {
183+
if (error instanceof TRPCError) {
184+
throw error;
185+
}
186+
187+
if (process.env.NODE_ENV !== "production") {
188+
console.error("Payment verification error:", error);
189+
}
190+
191+
throw new TRPCError({
192+
code: "INTERNAL_SERVER_ERROR",
193+
message: "Failed to verify payment",
194+
});
195+
}
196+
}
197+
),
55198
});

0 commit comments

Comments
 (0)