@deiondz/better-auth-razorpay 1.0.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.
@@ -0,0 +1,188 @@
1
+ import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
2
+ import type Razorpay from 'razorpay'
3
+ import {
4
+ handleRazorpayError,
5
+ subscribeSchema,
6
+ type RazorpaySubscription,
7
+ type RazorpayUserRecord,
8
+ } from '../lib'
9
+
10
+ /**
11
+ * Creates a new subscription for the authenticated user.
12
+ *
13
+ * @param razorpay - The Razorpay instance initialized with API credentials
14
+ * @param plans - Array of valid plan IDs from Razorpay dashboard configuration
15
+ * @returns A Better Auth endpoint handler
16
+ *
17
+ * @remarks
18
+ * This endpoint:
19
+ * - Requires user authentication via session
20
+ * - Validates the plan ID against configured plans
21
+ * - Prevents duplicate active subscriptions
22
+ * - Creates subscription via Razorpay API
23
+ * - Stores subscription record in database
24
+ * - Updates user record with subscription information
25
+ *
26
+ * @example
27
+ * Request body:
28
+ * ```json
29
+ * {
30
+ * "plan_id": "plan_1234567890",
31
+ * "total_count": 12,
32
+ * "quantity": 1,
33
+ * "customer_notify": true
34
+ * }
35
+ * ```
36
+ */
37
+ export const subscribe = (razorpay: Razorpay, plans: string[]) =>
38
+ createAuthEndpoint(
39
+ '/razorpay/subscribe',
40
+ { method: 'POST', use: [sessionMiddleware] },
41
+ async (_ctx) => {
42
+ try {
43
+ // Validate input using Zod schema
44
+ const validatedInput = subscribeSchema.parse(_ctx.body)
45
+
46
+ // Check if plan ID exists in configured plans array
47
+ if (!plans.includes(validatedInput.plan_id)) {
48
+ return {
49
+ success: false,
50
+ error: {
51
+ code: 'PLAN_NOT_FOUND',
52
+ description: 'Plan not found in configured plans',
53
+ },
54
+ }
55
+ }
56
+
57
+ // Get user ID from session
58
+ const userId = _ctx.context.session?.user?.id
59
+
60
+ if (!userId) {
61
+ return {
62
+ success: false,
63
+ error: {
64
+ code: 'UNAUTHORIZED',
65
+ description: 'User not authenticated',
66
+ },
67
+ }
68
+ }
69
+
70
+ // Check if user already has an active subscription
71
+ const user = (await _ctx.context.adapter.findOne({
72
+ model: 'user',
73
+ where: [{ field: 'id', value: userId }],
74
+ })) as RazorpayUserRecord | null
75
+
76
+ if (!user) {
77
+ return {
78
+ success: false,
79
+ error: {
80
+ code: 'USER_NOT_FOUND',
81
+ description: 'User not found',
82
+ },
83
+ }
84
+ }
85
+
86
+ const existingSubscriptionId = user.subscriptionId
87
+ const existingSubscriptionStatus = user.subscriptionStatus
88
+
89
+ // Prevent creating new subscription if user already has an active subscription
90
+ if (existingSubscriptionId) {
91
+ // Check if the existing subscription is still active (not cancelled)
92
+ const activeStatuses = ['active', 'authenticated', 'paused', 'created']
93
+ if (existingSubscriptionStatus && activeStatuses.includes(existingSubscriptionStatus)) {
94
+ return {
95
+ success: false,
96
+ error: {
97
+ code: 'SUBSCRIPTION_ALREADY_EXISTS',
98
+ description:
99
+ 'You already have an active subscription. Please cancel or pause your current subscription before creating a new one.',
100
+ },
101
+ }
102
+ }
103
+ }
104
+
105
+ // Create subscription via Razorpay API
106
+ const subscriptionData = {
107
+ plan_id: validatedInput.plan_id,
108
+ total_count: validatedInput.total_count,
109
+ quantity: validatedInput.quantity,
110
+ customer_notify: validatedInput.customer_notify,
111
+ ...(validatedInput.start_at && { start_at: validatedInput.start_at }),
112
+ ...(validatedInput.expire_by && { expire_by: validatedInput.expire_by }),
113
+ ...(validatedInput.addons &&
114
+ validatedInput.addons.length > 0 && { addons: validatedInput.addons }),
115
+ ...(validatedInput.offer_id && { offer_id: validatedInput.offer_id }),
116
+ ...(validatedInput.notes && { notes: validatedInput.notes }),
117
+ }
118
+
119
+ const subscription = (await razorpay.subscriptions.create(
120
+ subscriptionData
121
+ )) as RazorpaySubscription
122
+
123
+ // Store subscription in database
124
+ await _ctx.context.adapter.create({
125
+ model: 'razorpaySubscription',
126
+ data: {
127
+ userId,
128
+ subscriptionId: subscription.id,
129
+ planId: validatedInput.plan_id,
130
+ status: subscription.status,
131
+ },
132
+ })
133
+
134
+ // Update user table with subscription info
135
+ await _ctx.context.adapter.update({
136
+ model: 'user',
137
+ where: [{ field: 'id', value: userId }],
138
+ update: {
139
+ data: {
140
+ subscriptionStatus: subscription.status,
141
+ subscriptionId: subscription.id,
142
+ subscriptionPlanId: validatedInput.plan_id,
143
+ subscriptionCurrentPeriodEnd: subscription.current_end
144
+ ? new Date(subscription.current_end * 1000)
145
+ : null,
146
+ cancelAtPeriodEnd: false, // Initialize as not cancelling
147
+ lastPaymentDate: new Date(), // Set initial payment date
148
+ nextBillingDate: subscription.current_end
149
+ ? new Date(subscription.current_end * 1000)
150
+ : null,
151
+ },
152
+ },
153
+ })
154
+
155
+ return {
156
+ success: true,
157
+ data: {
158
+ id: subscription.id,
159
+ entity: subscription.entity,
160
+ plan_id: subscription.plan_id,
161
+ status: subscription.status,
162
+ current_start: subscription.current_start,
163
+ current_end: subscription.current_end,
164
+ ended_at: subscription.ended_at,
165
+ quantity: subscription.quantity,
166
+ notes: subscription.notes,
167
+ charge_at: subscription.charge_at,
168
+ start_at: subscription.start_at,
169
+ end_at: subscription.end_at,
170
+ auth_attempts: subscription.auth_attempts,
171
+ total_count: subscription.total_count,
172
+ paid_count: subscription.paid_count,
173
+ customer_notify: subscription.customer_notify,
174
+ created_at: subscription.created_at,
175
+ expire_by: subscription.expire_by,
176
+ short_url: subscription.short_url,
177
+ has_scheduled_changes: subscription.has_scheduled_changes,
178
+ change_scheduled_at: subscription.change_scheduled_at,
179
+ source: subscription.source,
180
+ offer_id: subscription.offer_id,
181
+ remaining_count: subscription.remaining_count,
182
+ },
183
+ }
184
+ } catch (error) {
185
+ return handleRazorpayError(error)
186
+ }
187
+ }
188
+ )
@@ -0,0 +1,129 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
3
+ import { handleRazorpayError, type RazorpaySubscriptionRecord, verifyPaymentSchema } from '../lib'
4
+
5
+ /**
6
+ * Verifies a payment signature after Razorpay checkout completion.
7
+ *
8
+ * @param keySecret - The Razorpay key secret used for signature verification
9
+ * @returns A Better Auth endpoint handler
10
+ *
11
+ * @remarks
12
+ * This endpoint:
13
+ * - Requires user authentication via session
14
+ * - Verifies payment signature using HMAC SHA256
15
+ * - Validates subscription ownership
16
+ * - Updates subscription status to 'authenticated'
17
+ * - Updates user record with payment date
18
+ * - Should be called after successful Razorpay checkout
19
+ *
20
+ * @example
21
+ * Request body:
22
+ * ```json
23
+ * {
24
+ * "razorpay_payment_id": "pay_1234567890",
25
+ * "razorpay_subscription_id": "sub_1234567890",
26
+ * "razorpay_signature": "abc123..."
27
+ * }
28
+ * ```
29
+ */
30
+ export const verifyPayment = (keySecret: string) =>
31
+ createAuthEndpoint(
32
+ '/razorpay/verify-payment',
33
+ { method: 'POST', use: [sessionMiddleware] },
34
+ async (_ctx) => {
35
+ try {
36
+ // Validate input using Zod schema
37
+ const validatedInput = verifyPaymentSchema.parse(_ctx.body)
38
+
39
+ const { razorpay_payment_id, razorpay_subscription_id, razorpay_signature } = validatedInput
40
+
41
+ // Generate expected signature
42
+ const generated_signature = createHmac('sha256', keySecret)
43
+ .update(`${razorpay_payment_id}|${razorpay_subscription_id}`)
44
+ .digest('hex')
45
+
46
+ // Verify signature
47
+ if (generated_signature !== razorpay_signature) {
48
+ return {
49
+ success: false,
50
+ error: {
51
+ code: 'SIGNATURE_VERIFICATION_FAILED',
52
+ description: 'Payment signature verification failed',
53
+ },
54
+ }
55
+ }
56
+
57
+ // Get user ID from session
58
+ const userId = _ctx.context.session?.user?.id
59
+
60
+ if (!userId) {
61
+ return {
62
+ success: false,
63
+ error: {
64
+ code: 'UNAUTHORIZED',
65
+ description: 'User not authenticated',
66
+ },
67
+ }
68
+ }
69
+
70
+ // Get subscription record to verify it belongs to the user
71
+ const subscriptionRecord = (await _ctx.context.adapter.findOne({
72
+ model: 'razorpaySubscription',
73
+ where: [{ field: 'subscriptionId', value: razorpay_subscription_id }],
74
+ })) as RazorpaySubscriptionRecord | null
75
+
76
+ if (!subscriptionRecord) {
77
+ return {
78
+ success: false,
79
+ error: {
80
+ code: 'SUBSCRIPTION_NOT_FOUND',
81
+ description: 'Subscription not found',
82
+ },
83
+ }
84
+ }
85
+
86
+ // Verify that the subscription belongs to the authenticated user
87
+ if (subscriptionRecord.userId !== userId) {
88
+ return {
89
+ success: false,
90
+ error: {
91
+ code: 'UNAUTHORIZED',
92
+ description: 'Subscription does not belong to authenticated user',
93
+ },
94
+ }
95
+ }
96
+
97
+ // Update subscription status to authenticated/active
98
+ await _ctx.context.adapter.update({
99
+ model: 'razorpaySubscription',
100
+ where: [{ field: 'subscriptionId', value: razorpay_subscription_id }],
101
+ update: { status: 'authenticated' },
102
+ })
103
+
104
+ // Update user table with subscription status and payment date
105
+ await _ctx.context.adapter.update({
106
+ model: 'user',
107
+ where: [{ field: 'id', value: userId }],
108
+ update: {
109
+ data: {
110
+ subscriptionStatus: 'authenticated',
111
+ subscriptionId: razorpay_subscription_id,
112
+ lastPaymentDate: new Date(),
113
+ },
114
+ },
115
+ })
116
+
117
+ return {
118
+ success: true,
119
+ data: {
120
+ message: 'Payment verified successfully',
121
+ payment_id: razorpay_payment_id,
122
+ subscription_id: razorpay_subscription_id,
123
+ },
124
+ }
125
+ } catch (error) {
126
+ return handleRazorpayError(error)
127
+ }
128
+ }
129
+ )
package/api/webhook.ts ADDED
@@ -0,0 +1,273 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import { createAuthEndpoint } from 'better-auth/api'
3
+ import type { OnWebhookEventCallback } from '../lib/types'
4
+
5
+ export interface WebhookResult {
6
+ success: boolean
7
+ message?: string
8
+ }
9
+
10
+ interface SubscriptionEntity {
11
+ id: string
12
+ plan_id: string
13
+ status: string
14
+ current_start?: number
15
+ current_end?: number
16
+ }
17
+
18
+ interface WebhookContext {
19
+ adapter: {
20
+ findOne: (params: {
21
+ model: string
22
+ where: { field: string; value: string }[]
23
+ }) => Promise<unknown>
24
+ update: (params: {
25
+ model: string
26
+ where: { field: string; value: string }[]
27
+ update: Record<string, unknown>
28
+ }) => Promise<unknown>
29
+ }
30
+ }
31
+
32
+ type EventHandler = (
33
+ adapter: WebhookContext['adapter'],
34
+ subscriptionId: string,
35
+ userId: string,
36
+ subscription: SubscriptionEntity
37
+ ) => Promise<void>
38
+
39
+ const updateSubscriptionAndUser = async (
40
+ adapter: WebhookContext['adapter'],
41
+ subscriptionId: string,
42
+ userId: string,
43
+ status: string,
44
+ extraUserFields?: Record<string, unknown>
45
+ ): Promise<void> => {
46
+ await adapter.update({
47
+ model: 'razorpaySubscription',
48
+ where: [{ field: 'subscriptionId', value: subscriptionId }],
49
+ update: { status },
50
+ })
51
+
52
+ await adapter.update({
53
+ model: 'user',
54
+ where: [{ field: 'id', value: userId }],
55
+ update: { subscriptionStatus: status, ...extraUserFields },
56
+ })
57
+ }
58
+
59
+ const createStatusHandler =
60
+ (
61
+ status: string,
62
+ extraUserFields?: (sub: SubscriptionEntity) => Record<string, unknown>
63
+ ): EventHandler =>
64
+ async (adapter, subscriptionId, userId, subscription) => {
65
+ const extra = extraUserFields ? extraUserFields(subscription) : {}
66
+ await updateSubscriptionAndUser(adapter, subscriptionId, userId, status, extra)
67
+ }
68
+
69
+ const eventHandlers: Record<string, EventHandler> = {
70
+ 'subscription.authenticated': createStatusHandler('authenticated', (sub) => ({
71
+ subscriptionId: sub.id,
72
+ subscriptionPlanId: sub.plan_id,
73
+ })),
74
+ 'subscription.activated': createStatusHandler('active', (sub) => ({
75
+ subscriptionId: sub.id,
76
+ subscriptionPlanId: sub.plan_id,
77
+ })),
78
+ 'subscription.charged': createStatusHandler('active', (sub) => ({
79
+ lastPaymentDate: new Date(),
80
+ nextBillingDate: sub.current_end ? new Date(sub.current_end * 1000) : null,
81
+ subscriptionCurrentPeriodEnd: sub.current_end ? new Date(sub.current_end * 1000) : null,
82
+ })),
83
+ 'subscription.cancelled': createStatusHandler('cancelled', () => ({ cancelAtPeriodEnd: false })),
84
+ 'subscription.paused': createStatusHandler('paused'),
85
+ 'subscription.resumed': createStatusHandler('active'),
86
+ 'subscription.pending': createStatusHandler('pending'),
87
+ 'subscription.halted': createStatusHandler('halted'),
88
+ }
89
+
90
+ const getRawBody = async (request: Request | undefined, fallbackBody: unknown): Promise<string> => {
91
+ if (!request) {
92
+ return JSON.stringify(fallbackBody)
93
+ }
94
+
95
+ try {
96
+ const clonedRequest = request.clone()
97
+ const text = await clonedRequest.text()
98
+ return text || JSON.stringify(fallbackBody)
99
+ } catch {
100
+ return JSON.stringify(fallbackBody)
101
+ }
102
+ }
103
+
104
+ const verifySignature = (rawBody: string, signature: string, secret: string): boolean => {
105
+ const expectedSignature = createHmac('sha256', secret).update(rawBody).digest('hex')
106
+ return signature === expectedSignature
107
+ }
108
+
109
+ const invokeCallback = async (
110
+ onWebhookEvent: OnWebhookEventCallback,
111
+ adapter: WebhookContext['adapter'],
112
+ event: string,
113
+ subscription: SubscriptionEntity,
114
+ payload: { payment?: { entity?: { id: string; amount: number; currency?: string } } },
115
+ userId: string
116
+ ): Promise<void> => {
117
+ const user = (await adapter.findOne({
118
+ model: 'user',
119
+ where: [{ field: 'id', value: userId }],
120
+ })) as { id: string; email: string; name: string } | null
121
+
122
+ if (!user) return
123
+
124
+ await onWebhookEvent(
125
+ {
126
+ event: event as Parameters<OnWebhookEventCallback>[0]['event'],
127
+ subscription: {
128
+ id: subscription.id,
129
+ plan_id: subscription.plan_id,
130
+ status: subscription.status,
131
+ current_start: subscription.current_start,
132
+ current_end: subscription.current_end,
133
+ },
134
+ payment: payload.payment?.entity
135
+ ? {
136
+ id: payload.payment.entity.id,
137
+ amount: payload.payment.entity.amount,
138
+ currency: payload.payment.entity.currency || 'INR',
139
+ }
140
+ : undefined,
141
+ },
142
+ { userId, user: { id: user.id, email: user.email, name: user.name } }
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Handles Razorpay webhook events for subscription lifecycle management.
148
+ *
149
+ * @param webhookSecret - Optional webhook secret for signature verification
150
+ * @param onWebhookEvent - Optional callback function invoked after webhook processing
151
+ * @returns A Better Auth endpoint handler
152
+ *
153
+ * @remarks
154
+ * This endpoint:
155
+ * - Verifies webhook signature using HMAC SHA256
156
+ * - Processes subscription events (authenticated, activated, charged, cancelled, paused, resumed, etc.)
157
+ * - Updates subscription and user records based on event type
158
+ * - Invokes optional callback for custom business logic
159
+ * - Does not require authentication (webhook endpoint)
160
+ *
161
+ * @example
162
+ * Supported events:
163
+ * - subscription.authenticated
164
+ * - subscription.activated
165
+ * - subscription.charged
166
+ * - subscription.cancelled
167
+ * - subscription.paused
168
+ * - subscription.resumed
169
+ * - subscription.pending
170
+ * - subscription.halted
171
+ */
172
+ export const webhook = (webhookSecret?: string, onWebhookEvent?: OnWebhookEventCallback) =>
173
+ createAuthEndpoint('/razorpay/webhook', { method: 'POST' }, async (_ctx) => {
174
+ if (!webhookSecret) {
175
+ return { success: false, message: 'Webhook secret not configured' }
176
+ }
177
+
178
+ const signature = _ctx.request?.headers.get('x-razorpay-signature')
179
+ if (!signature) {
180
+ return { success: false, message: 'Missing webhook signature' }
181
+ }
182
+
183
+ const rawBody = await getRawBody(_ctx.request, _ctx.body)
184
+
185
+ if (!verifySignature(rawBody, signature, webhookSecret)) {
186
+ return { success: false, message: 'Invalid webhook signature' }
187
+ }
188
+
189
+ return processWebhookEvent(_ctx.context.adapter, rawBody, _ctx.body, onWebhookEvent)
190
+ })
191
+
192
+ const processWebhookEvent = async (
193
+ adapter: WebhookContext['adapter'],
194
+ rawBody: string,
195
+ fallbackBody: unknown,
196
+ onWebhookEvent?: OnWebhookEventCallback
197
+ ): Promise<WebhookResult> => {
198
+ const isDev = process.env.NODE_ENV === 'development'
199
+
200
+ try {
201
+ const webhookData = rawBody ? JSON.parse(rawBody) : fallbackBody
202
+ const { event, payload } = webhookData
203
+
204
+ if (!event || !payload) {
205
+ return {
206
+ success: false,
207
+ message: isDev
208
+ ? 'Invalid webhook payload: missing event or payload'
209
+ : 'Invalid webhook payload',
210
+ }
211
+ }
212
+
213
+ const subscription = payload.subscription?.entity as SubscriptionEntity | undefined
214
+ if (!subscription) {
215
+ return {
216
+ success: false,
217
+ message: isDev
218
+ ? 'Invalid webhook payload: missing subscription data'
219
+ : 'Invalid webhook payload',
220
+ }
221
+ }
222
+
223
+ const subscriptionRecord = (await adapter.findOne({
224
+ model: 'razorpaySubscription',
225
+ where: [{ field: 'subscriptionId', value: subscription.id }],
226
+ })) as { userId?: string } | null
227
+
228
+ if (!subscriptionRecord) {
229
+ return {
230
+ success: false,
231
+ message: isDev
232
+ ? `Subscription record not found for subscription ${subscription.id}`
233
+ : 'Subscription record not found',
234
+ }
235
+ }
236
+
237
+ const userId = subscriptionRecord.userId
238
+ if (!userId) {
239
+ return {
240
+ success: false,
241
+ message: isDev
242
+ ? `User ID not found in subscription record for subscription ${subscription.id}`
243
+ : 'User ID not found in subscription record',
244
+ }
245
+ }
246
+
247
+ const handler = eventHandlers[event]
248
+ if (!handler) {
249
+ return {
250
+ success: false,
251
+ message: isDev ? `Unhandled event: ${event}` : 'Unhandled webhook event',
252
+ }
253
+ }
254
+
255
+ await handler(adapter, subscription.id, userId, subscription)
256
+
257
+ if (onWebhookEvent) {
258
+ try {
259
+ await invokeCallback(onWebhookEvent, adapter, event, subscription, payload, userId)
260
+ } catch {
261
+ // Silently handle callback errors - they shouldn't break webhook processing
262
+ // The callback is for custom logic and failures there shouldn't affect core functionality
263
+ }
264
+ }
265
+
266
+ return { success: true }
267
+ } catch (error) {
268
+ return {
269
+ success: false,
270
+ message: error instanceof Error ? error.message : 'Webhook processing failed',
271
+ }
272
+ }
273
+ }
package/client.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { BetterAuthClientPlugin } from 'better-auth/client'
2
+ import type { razorpayPlugin } from './index'
3
+
4
+ export const razorpayClientPlugin = () =>
5
+ ({
6
+ id: 'razorpay-plugin',
7
+ $InferServerPlugin: {} as ReturnType<typeof razorpayPlugin>,
8
+ }) satisfies BetterAuthClientPlugin