@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,135 @@
1
+ import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
2
+ import type Razorpay from 'razorpay'
3
+ import {
4
+ cancelSubscriptionSchema,
5
+ handleRazorpayError,
6
+ type RazorpaySubscription,
7
+ type RazorpaySubscriptionRecord,
8
+ } from '../lib'
9
+
10
+ /**
11
+ * Cancels a subscription at the end of the current billing period.
12
+ *
13
+ * @param razorpay - The Razorpay instance initialized with API credentials
14
+ * @returns A Better Auth endpoint handler
15
+ *
16
+ * @remarks
17
+ * This endpoint:
18
+ * - Requires user authentication via session
19
+ * - Verifies subscription ownership before cancellation
20
+ * - Cancels subscription at period end (not immediately)
21
+ * - Updates user record with cancellation flag and period end date
22
+ * - Subscription remains active until the current period ends
23
+ *
24
+ * @example
25
+ * Request body:
26
+ * ```json
27
+ * {
28
+ * "subscription_id": "sub_1234567890"
29
+ * }
30
+ * ```
31
+ */
32
+ export const cancelSubscription = (razorpay: Razorpay) =>
33
+ createAuthEndpoint(
34
+ '/razorpay/cancel-subscription',
35
+ { method: 'POST', use: [sessionMiddleware] },
36
+ async (_ctx) => {
37
+ try {
38
+ // Validate input using Zod schema
39
+ const validatedInput = cancelSubscriptionSchema.parse(_ctx.body)
40
+
41
+ // Get user ID from session
42
+ const userId = _ctx.context.session?.user?.id
43
+
44
+ if (!userId) {
45
+ return {
46
+ success: false,
47
+ error: {
48
+ code: 'UNAUTHORIZED',
49
+ description: 'User not authenticated',
50
+ },
51
+ }
52
+ }
53
+
54
+ // Get subscription record to verify it belongs to the user
55
+ const subscriptionRecord = (await _ctx.context.adapter.findOne({
56
+ model: 'razorpaySubscription',
57
+ where: [{ field: 'subscriptionId', value: validatedInput.subscription_id }],
58
+ })) as RazorpaySubscriptionRecord | null
59
+
60
+ if (!subscriptionRecord) {
61
+ return {
62
+ success: false,
63
+ error: {
64
+ code: 'SUBSCRIPTION_NOT_FOUND',
65
+ description: 'Subscription not found',
66
+ },
67
+ }
68
+ }
69
+
70
+ // Verify that the subscription belongs to the authenticated user
71
+ const subscriptionUserId = subscriptionRecord.userId
72
+ if (subscriptionUserId !== userId) {
73
+ return {
74
+ success: false,
75
+ error: {
76
+ code: 'UNAUTHORIZED',
77
+ description: 'Subscription does not belong to authenticated user',
78
+ },
79
+ }
80
+ }
81
+
82
+ // Cancel subscription via Razorpay API (cancel at period end)
83
+ const subscription = (await razorpay.subscriptions.cancel(
84
+ validatedInput.subscription_id,
85
+ true
86
+ )) as RazorpaySubscription
87
+
88
+ // Update user table with cancellation info (keep active status)
89
+ await _ctx.context.adapter.update({
90
+ model: 'user',
91
+ where: [{ field: 'id', value: userId }],
92
+ update: {
93
+ data: {
94
+ cancelAtPeriodEnd: true,
95
+ subscriptionCurrentPeriodEnd: subscription.current_end
96
+ ? new Date(subscription.current_end * 1000)
97
+ : null,
98
+ },
99
+ },
100
+ })
101
+
102
+ return {
103
+ success: true,
104
+ data: {
105
+ id: subscription.id,
106
+ entity: subscription.entity,
107
+ plan_id: subscription.plan_id,
108
+ status: subscription.status,
109
+ current_start: subscription.current_start,
110
+ current_end: subscription.current_end,
111
+ ended_at: subscription.ended_at,
112
+ quantity: subscription.quantity,
113
+ notes: subscription.notes,
114
+ charge_at: subscription.charge_at,
115
+ start_at: subscription.start_at,
116
+ end_at: subscription.end_at,
117
+ auth_attempts: subscription.auth_attempts,
118
+ total_count: subscription.total_count,
119
+ paid_count: subscription.paid_count,
120
+ customer_notify: subscription.customer_notify,
121
+ created_at: subscription.created_at,
122
+ expire_by: subscription.expire_by,
123
+ short_url: subscription.short_url,
124
+ has_scheduled_changes: subscription.has_scheduled_changes,
125
+ change_scheduled_at: subscription.change_scheduled_at,
126
+ source: subscription.source,
127
+ offer_id: subscription.offer_id,
128
+ remaining_count: subscription.remaining_count,
129
+ },
130
+ }
131
+ } catch (error) {
132
+ return handleRazorpayError(error)
133
+ }
134
+ }
135
+ )
@@ -0,0 +1,63 @@
1
+ import { createAuthEndpoint } from 'better-auth/api'
2
+ import type Razorpay from 'razorpay'
3
+ import { getPlansSchema, handleRazorpayError } from '../lib'
4
+
5
+ /**
6
+ * Retrieves plan details from Razorpay for configured plan IDs.
7
+ *
8
+ * @param razorpay - The Razorpay instance initialized with API credentials
9
+ * @param planIds - Array of plan IDs configured in the plugin options
10
+ * @returns A Better Auth endpoint handler
11
+ *
12
+ * @remarks
13
+ * This endpoint:
14
+ * - Fetches plan details from Razorpay API
15
+ * - Silently skips plans that fail to fetch (filters them out)
16
+ * - Returns all successfully fetched plans
17
+ * - Does not require authentication (public endpoint)
18
+ *
19
+ * @example
20
+ * Response (success):
21
+ * ```json
22
+ * {
23
+ * "success": true,
24
+ * "data": [
25
+ * {
26
+ * "id": "plan_1234567890",
27
+ * "name": "Premium Plan",
28
+ * "amount": 1000,
29
+ * ...
30
+ * }
31
+ * ]
32
+ * }
33
+ * ```
34
+ */
35
+ export const getPlans = (razorpay: Razorpay, planIds: string[]) =>
36
+ createAuthEndpoint('/razorpay/get-plans', { method: 'GET' }, async (_ctx) => {
37
+ try {
38
+ // GET requests don't have a body, so validation is optional
39
+ // Schema allows undefined for GET requests
40
+ getPlansSchema.parse(_ctx.body)
41
+
42
+ // Fetch plan details from Razorpay API using configured plan IDs
43
+ const plans = await Promise.all(
44
+ planIds.map(async (planId) => {
45
+ try {
46
+ const plan = await razorpay.plans.fetch(planId)
47
+ return plan
48
+ } catch {
49
+ // Silently skip failed plan fetches - they will be filtered out
50
+ // Individual plan fetch failures shouldn't break the entire request
51
+ return null
52
+ }
53
+ })
54
+ )
55
+
56
+ // Filter out any null values from failed fetches
57
+ const validPlans = plans.filter((plan) => plan !== null)
58
+
59
+ return { success: true, data: validPlans }
60
+ } catch (error) {
61
+ return handleRazorpayError(error)
62
+ }
63
+ })
@@ -0,0 +1,174 @@
1
+ import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
2
+ import type Razorpay from 'razorpay'
3
+ import {
4
+ handleRazorpayError,
5
+ type RazorpaySubscription,
6
+ type RazorpayUserRecord,
7
+ } from '../lib'
8
+
9
+ /**
10
+ * Retrieves the current subscription details for the authenticated user.
11
+ *
12
+ * @param razorpay - The Razorpay instance initialized with API credentials
13
+ * @returns A Better Auth endpoint handler
14
+ *
15
+ * @remarks
16
+ * This endpoint:
17
+ * - Requires user authentication via session
18
+ * - Fetches subscription details from Razorpay API
19
+ * - Includes cancellation status and period end information
20
+ * - Returns null if user has no active subscription
21
+ * - Provides detailed error messages in development mode
22
+ *
23
+ * @example
24
+ * Response (success):
25
+ * ```json
26
+ * {
27
+ * "success": true,
28
+ * "data": {
29
+ * "id": "sub_1234567890",
30
+ * "status": "active",
31
+ * "plan_id": "plan_1234567890",
32
+ * "cancel_at_period_end": false,
33
+ * ...
34
+ * }
35
+ * }
36
+ * ```
37
+ */
38
+ export const getSubscription = (razorpay: Razorpay) =>
39
+ createAuthEndpoint(
40
+ '/razorpay/get-subscription',
41
+ { method: 'GET', use: [sessionMiddleware] },
42
+ async (_ctx) => {
43
+ try {
44
+ // Get user ID from session
45
+ const userId = _ctx.context.session?.user?.id
46
+
47
+ if (!userId) {
48
+ return {
49
+ success: false,
50
+ error: {
51
+ code: 'UNAUTHORIZED',
52
+ description: 'User not authenticated',
53
+ },
54
+ }
55
+ }
56
+
57
+ // Get user record to check subscription status
58
+ const user = (await _ctx.context.adapter.findOne({
59
+ model: 'user',
60
+ where: [{ field: 'id', value: userId }],
61
+ })) as RazorpayUserRecord | null
62
+
63
+ if (!user) {
64
+ return {
65
+ success: false,
66
+ error: {
67
+ code: 'USER_NOT_FOUND',
68
+ description: 'User not found',
69
+ },
70
+ }
71
+ }
72
+
73
+ // Check subscription status from user table
74
+ const subscriptionId = user.subscriptionId
75
+
76
+ if (!subscriptionId) {
77
+ return {
78
+ success: true,
79
+ data: null,
80
+ }
81
+ }
82
+
83
+ // Fetch full subscription details from Razorpay API
84
+ try {
85
+ const subscription = (await razorpay.subscriptions.fetch(
86
+ subscriptionId
87
+ )) as RazorpaySubscription
88
+
89
+ // Read cancellation status from user table
90
+ const cancelAtPeriodEnd = user.cancelAtPeriodEnd ?? false
91
+ const subscriptionCurrentPeriodEnd = user.subscriptionCurrentPeriodEnd
92
+
93
+ return {
94
+ success: true,
95
+ data: {
96
+ id: subscription.id,
97
+ entity: subscription.entity,
98
+ plan_id: subscription.plan_id,
99
+ status: subscription.status,
100
+ current_start: subscription.current_start,
101
+ current_end: subscription.current_end,
102
+ ended_at: subscription.ended_at,
103
+ quantity: subscription.quantity,
104
+ notes: subscription.notes,
105
+ charge_at: subscription.charge_at,
106
+ start_at: subscription.start_at,
107
+ end_at: subscription.end_at,
108
+ auth_attempts: subscription.auth_attempts,
109
+ total_count: subscription.total_count,
110
+ paid_count: subscription.paid_count,
111
+ customer_notify: subscription.customer_notify,
112
+ created_at: subscription.created_at,
113
+ expire_by: subscription.expire_by,
114
+ short_url: subscription.short_url,
115
+ has_scheduled_changes: subscription.has_scheduled_changes,
116
+ change_scheduled_at: subscription.change_scheduled_at,
117
+ source: subscription.source,
118
+ offer_id: subscription.offer_id,
119
+ remaining_count: subscription.remaining_count,
120
+ cancel_at_period_end: cancelAtPeriodEnd,
121
+ subscription_current_period_end: subscriptionCurrentPeriodEnd
122
+ ? Math.floor(new Date(subscriptionCurrentPeriodEnd).getTime() / 1000)
123
+ : null,
124
+ },
125
+ }
126
+ } catch (razorpayError) {
127
+ // Handle Razorpay-specific errors with subscription ID context
128
+ const isDev = process.env.NODE_ENV === 'development'
129
+
130
+ // Extract error message from Razorpay error
131
+ let errorMessage = 'Failed to fetch subscription'
132
+ let errorCode = 'SUBSCRIPTION_FETCH_FAILED'
133
+
134
+ if (razorpayError && typeof razorpayError === 'object') {
135
+ // Razorpay error format: { error: { code: string, description: string } }
136
+ if ('error' in razorpayError) {
137
+ const razorpayErr = razorpayError as {
138
+ error?: { code?: string; description?: string }
139
+ }
140
+ errorCode = razorpayErr.error?.code || errorCode
141
+ errorMessage = razorpayErr.error?.description || errorMessage
142
+ } else if ('message' in razorpayError) {
143
+ errorMessage = (razorpayError as { message: string }).message
144
+ }
145
+ }
146
+
147
+ // Check if error is specifically about subscription not existing
148
+ const isNotFoundError =
149
+ errorMessage.toLowerCase().includes('does not exist') ||
150
+ errorMessage.toLowerCase().includes('not found') ||
151
+ errorCode === 'BAD_REQUEST_ERROR'
152
+
153
+ // Include subscription ID in error for debugging
154
+ const description = isDev
155
+ ? `${errorMessage} (Subscription ID: ${subscriptionId})`
156
+ : isNotFoundError
157
+ ? 'The subscription could not be found. This may indicate the subscription was deleted or the ID is invalid. Please contact support if this issue persists.'
158
+ : 'Unable to retrieve subscription information. Please try again or contact support if this issue persists.'
159
+
160
+ return {
161
+ success: false,
162
+ error: {
163
+ code: errorCode,
164
+ description,
165
+ // Include subscription ID in metadata for development
166
+ ...(isDev && { subscriptionId }),
167
+ },
168
+ }
169
+ }
170
+ } catch (error) {
171
+ return handleRazorpayError(error)
172
+ }
173
+ }
174
+ )
package/api/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { cancelSubscription } from './cancel-subscription'
2
+ export { getPlans } from './get-plans'
3
+ export { getSubscription } from './get-subscription'
4
+ export { pauseSubscription } from './pause-subscription'
5
+ export { resumeSubscription } from './resume-subscription'
6
+ export { subscribe } from './subscribe'
7
+ export { verifyPayment } from './verify-payment'
8
+ export { webhook } from './webhook'
@@ -0,0 +1,138 @@
1
+ import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
2
+ import type Razorpay from 'razorpay'
3
+ import {
4
+ handleRazorpayError,
5
+ pauseSubscriptionSchema,
6
+ type RazorpaySubscription,
7
+ type RazorpaySubscriptionRecord,
8
+ } from '../lib'
9
+
10
+ /**
11
+ * Pauses an active subscription.
12
+ *
13
+ * @param razorpay - The Razorpay instance initialized with API credentials
14
+ * @returns A Better Auth endpoint handler
15
+ *
16
+ * @remarks
17
+ * This endpoint:
18
+ * - Requires user authentication via session
19
+ * - Verifies subscription ownership before pausing
20
+ * - Pauses the subscription via Razorpay API
21
+ * - Updates subscription and user records with paused status
22
+ * - Paused subscriptions can be resumed later
23
+ *
24
+ * @example
25
+ * Request body:
26
+ * ```json
27
+ * {
28
+ * "subscription_id": "sub_1234567890"
29
+ * }
30
+ * ```
31
+ */
32
+ export const pauseSubscription = (razorpay: Razorpay) =>
33
+ createAuthEndpoint(
34
+ '/razorpay/pause-subscription',
35
+ { method: 'POST', use: [sessionMiddleware] },
36
+ async (_ctx) => {
37
+ try {
38
+ // Validate input using Zod schema
39
+ const validatedInput = pauseSubscriptionSchema.parse(_ctx.body)
40
+
41
+ // Get user ID from session
42
+ const userId = _ctx.context.session?.user?.id
43
+
44
+ if (!userId) {
45
+ return {
46
+ success: false,
47
+ error: {
48
+ code: 'UNAUTHORIZED',
49
+ description: 'User not authenticated',
50
+ },
51
+ }
52
+ }
53
+
54
+ // Get subscription record to verify it belongs to the user
55
+ const subscriptionRecord = (await _ctx.context.adapter.findOne({
56
+ model: 'razorpaySubscription',
57
+ where: [{ field: 'subscriptionId', value: validatedInput.subscription_id }],
58
+ })) as RazorpaySubscriptionRecord | null
59
+
60
+ if (!subscriptionRecord) {
61
+ return {
62
+ success: false,
63
+ error: {
64
+ code: 'SUBSCRIPTION_NOT_FOUND',
65
+ description: 'Subscription not found',
66
+ },
67
+ }
68
+ }
69
+
70
+ // Verify that the subscription belongs to the authenticated user
71
+ const subscriptionUserId = subscriptionRecord.userId
72
+ if (subscriptionUserId !== userId) {
73
+ return {
74
+ success: false,
75
+ error: {
76
+ code: 'UNAUTHORIZED',
77
+ description: 'Subscription does not belong to authenticated user',
78
+ },
79
+ }
80
+ }
81
+
82
+ // Pause subscription via Razorpay API
83
+ const subscription = (await razorpay.subscriptions.pause(
84
+ validatedInput.subscription_id
85
+ )) as RazorpaySubscription
86
+
87
+ // Update subscription status in database
88
+ await _ctx.context.adapter.update({
89
+ model: 'razorpaySubscription',
90
+ where: [{ field: 'subscriptionId', value: validatedInput.subscription_id }],
91
+ update: { status: subscription.status },
92
+ })
93
+
94
+ // Update user table with subscription status
95
+ await _ctx.context.adapter.update({
96
+ model: 'user',
97
+ where: [{ field: 'id', value: userId }],
98
+ update: {
99
+ data: {
100
+ subscriptionStatus: subscription.status,
101
+ },
102
+ },
103
+ })
104
+
105
+ return {
106
+ success: true,
107
+ data: {
108
+ id: subscription.id,
109
+ entity: subscription.entity,
110
+ plan_id: subscription.plan_id,
111
+ status: subscription.status,
112
+ current_start: subscription.current_start,
113
+ current_end: subscription.current_end,
114
+ ended_at: subscription.ended_at,
115
+ quantity: subscription.quantity,
116
+ notes: subscription.notes,
117
+ charge_at: subscription.charge_at,
118
+ start_at: subscription.start_at,
119
+ end_at: subscription.end_at,
120
+ auth_attempts: subscription.auth_attempts,
121
+ total_count: subscription.total_count,
122
+ paid_count: subscription.paid_count,
123
+ customer_notify: subscription.customer_notify,
124
+ created_at: subscription.created_at,
125
+ expire_by: subscription.expire_by,
126
+ short_url: subscription.short_url,
127
+ has_scheduled_changes: subscription.has_scheduled_changes,
128
+ change_scheduled_at: subscription.change_scheduled_at,
129
+ source: subscription.source,
130
+ offer_id: subscription.offer_id,
131
+ remaining_count: subscription.remaining_count,
132
+ },
133
+ }
134
+ } catch (error) {
135
+ return handleRazorpayError(error)
136
+ }
137
+ }
138
+ )
@@ -0,0 +1,150 @@
1
+ import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
2
+ import type Razorpay from 'razorpay'
3
+ import {
4
+ handleRazorpayError,
5
+ type RazorpaySubscription,
6
+ resumeSubscriptionSchema,
7
+ type RazorpaySubscriptionRecord,
8
+ } from '../lib'
9
+
10
+ /**
11
+ * Resumes a paused subscription.
12
+ *
13
+ * @param razorpay - The Razorpay instance initialized with API credentials
14
+ * @returns A Better Auth endpoint handler
15
+ *
16
+ * @remarks
17
+ * This endpoint:
18
+ * - Requires user authentication via session
19
+ * - Verifies subscription ownership before resuming
20
+ * - Validates that subscription is in paused status
21
+ * - Resumes the subscription via Razorpay API
22
+ * - Updates subscription and user records with active status
23
+ *
24
+ * @example
25
+ * Request body:
26
+ * ```json
27
+ * {
28
+ * "subscription_id": "sub_1234567890"
29
+ * }
30
+ * ```
31
+ */
32
+ export const resumeSubscription = (razorpay: Razorpay) =>
33
+ createAuthEndpoint(
34
+ '/razorpay/resume-subscription',
35
+ { method: 'POST', use: [sessionMiddleware] },
36
+ async (_ctx) => {
37
+ try {
38
+ // Validate input using Zod schema
39
+ const validatedInput = resumeSubscriptionSchema.parse(_ctx.body)
40
+
41
+ // Get user ID from session
42
+ const userId = _ctx.context.session?.user?.id
43
+
44
+ if (!userId) {
45
+ return {
46
+ success: false,
47
+ error: {
48
+ code: 'UNAUTHORIZED',
49
+ description: 'User not authenticated',
50
+ },
51
+ }
52
+ }
53
+
54
+ // Get subscription record to verify it belongs to the user
55
+ const subscriptionRecord = (await _ctx.context.adapter.findOne({
56
+ model: 'razorpaySubscription',
57
+ where: [{ field: 'subscriptionId', value: validatedInput.subscription_id }],
58
+ })) as RazorpaySubscriptionRecord | null
59
+
60
+ if (!subscriptionRecord) {
61
+ return {
62
+ success: false,
63
+ error: {
64
+ code: 'SUBSCRIPTION_NOT_FOUND',
65
+ description: 'Subscription not found',
66
+ },
67
+ }
68
+ }
69
+
70
+ // Verify that the subscription belongs to the authenticated user
71
+ const subscriptionUserId = subscriptionRecord.userId
72
+ if (subscriptionUserId !== userId) {
73
+ return {
74
+ success: false,
75
+ error: {
76
+ code: 'UNAUTHORIZED',
77
+ description: 'Subscription does not belong to authenticated user',
78
+ },
79
+ }
80
+ }
81
+
82
+ // Check if subscription is paused
83
+ const subscriptionStatus = subscriptionRecord.status
84
+ if (subscriptionStatus !== 'paused') {
85
+ return {
86
+ success: false,
87
+ error: {
88
+ code: 'INVALID_STATUS',
89
+ description: 'Subscription is not paused. Only paused subscriptions can be resumed.',
90
+ },
91
+ }
92
+ }
93
+
94
+ // Resume subscription via Razorpay API
95
+ const subscription = (await razorpay.subscriptions.resume(
96
+ validatedInput.subscription_id
97
+ )) as RazorpaySubscription
98
+
99
+ // Update subscription status in database
100
+ await _ctx.context.adapter.update({
101
+ model: 'razorpaySubscription',
102
+ where: [{ field: 'subscriptionId', value: validatedInput.subscription_id }],
103
+ update: { status: subscription.status },
104
+ })
105
+
106
+ // Update user table with subscription status
107
+ await _ctx.context.adapter.update({
108
+ model: 'user',
109
+ where: [{ field: 'id', value: userId }],
110
+ update: {
111
+ data: {
112
+ subscriptionStatus: subscription.status,
113
+ },
114
+ },
115
+ })
116
+
117
+ return {
118
+ success: true,
119
+ data: {
120
+ id: subscription.id,
121
+ entity: subscription.entity,
122
+ plan_id: subscription.plan_id,
123
+ status: subscription.status,
124
+ current_start: subscription.current_start,
125
+ current_end: subscription.current_end,
126
+ ended_at: subscription.ended_at,
127
+ quantity: subscription.quantity,
128
+ notes: subscription.notes,
129
+ charge_at: subscription.charge_at,
130
+ start_at: subscription.start_at,
131
+ end_at: subscription.end_at,
132
+ auth_attempts: subscription.auth_attempts,
133
+ total_count: subscription.total_count,
134
+ paid_count: subscription.paid_count,
135
+ customer_notify: subscription.customer_notify,
136
+ created_at: subscription.created_at,
137
+ expire_by: subscription.expire_by,
138
+ short_url: subscription.short_url,
139
+ has_scheduled_changes: subscription.has_scheduled_changes,
140
+ change_scheduled_at: subscription.change_scheduled_at,
141
+ source: subscription.source,
142
+ offer_id: subscription.offer_id,
143
+ remaining_count: subscription.remaining_count,
144
+ },
145
+ }
146
+ } catch (error) {
147
+ return handleRazorpayError(error)
148
+ }
149
+ }
150
+ )