@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.
- package/README.md +1201 -0
- package/api/cancel-subscription.ts +135 -0
- package/api/get-plans.ts +63 -0
- package/api/get-subscription.ts +174 -0
- package/api/index.ts +8 -0
- package/api/pause-subscription.ts +138 -0
- package/api/resume-subscription.ts +150 -0
- package/api/subscribe.ts +188 -0
- package/api/verify-payment.ts +129 -0
- package/api/webhook.ts +273 -0
- package/client.ts +8 -0
- package/index.ts +123 -0
- package/lib/error-handler.ts +99 -0
- package/lib/index.ts +22 -0
- package/lib/schemas.ts +58 -0
- package/lib/types.ts +151 -0
- package/package.json +58 -0
package/api/subscribe.ts
ADDED
|
@@ -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
|