@donotdev/functions 0.0.12 → 0.0.13
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/package.json +4 -4
- package/src/firebase/auth/setCustomClaims.ts +19 -5
- package/src/firebase/baseFunction.ts +11 -3
- package/src/firebase/billing/changePlan.ts +5 -1
- package/src/firebase/billing/createCustomerPortal.ts +6 -2
- package/src/firebase/billing/webhookHandler.ts +4 -1
- package/src/firebase/crud/aggregate.ts +5 -1
- package/src/firebase/crud/create.ts +17 -4
- package/src/firebase/crud/list.ts +9 -4
- package/src/firebase/crud/update.ts +17 -4
- package/src/firebase/oauth/exchangeToken.ts +17 -4
- package/src/shared/__tests__/validation.test.ts +5 -3
- package/src/shared/billing/__tests__/createCheckout.test.ts +123 -22
- package/src/shared/billing/__tests__/webhookHandler.test.ts +121 -30
- package/src/shared/firebase.ts +1 -1
- package/src/shared/logger.ts +7 -1
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +6 -8
- package/src/shared/oauth/__tests__/grantAccess.test.ts +31 -14
- package/src/shared/utils/internal/auth.ts +10 -3
- package/src/shared/utils/internal/rateLimiter.ts +8 -2
- package/src/shared/utils.ts +5 -1
- package/src/supabase/auth/deleteAccount.ts +1 -1
- package/src/supabase/auth/getCustomClaims.ts +5 -3
- package/src/supabase/auth/getUserAuthStatus.ts +5 -3
- package/src/supabase/auth/removeCustomClaims.ts +10 -5
- package/src/supabase/auth/setCustomClaims.ts +9 -4
- package/src/supabase/baseFunction.ts +77 -22
- package/src/supabase/billing/cancelSubscription.ts +9 -3
- package/src/supabase/billing/changePlan.ts +20 -5
- package/src/supabase/billing/createCheckoutSession.ts +20 -5
- package/src/supabase/billing/createCustomerPortal.ts +14 -4
- package/src/supabase/billing/refreshSubscriptionStatus.ts +29 -9
- package/src/supabase/crud/aggregate.ts +14 -4
- package/src/supabase/crud/create.ts +30 -11
- package/src/supabase/crud/delete.ts +11 -3
- package/src/supabase/crud/get.ts +25 -3
- package/src/supabase/crud/list.ts +76 -22
- package/src/supabase/crud/update.ts +32 -10
- package/src/supabase/helpers/authProvider.ts +5 -2
- package/src/supabase/index.ts +1 -4
- package/src/supabase/registerCrudFunctions.ts +11 -9
- package/src/supabase/utils/idempotency.ts +13 -15
- package/src/supabase/utils/monitoring.ts +5 -1
- package/src/supabase/utils/rateLimiter.ts +13 -3
- package/src/vercel/api/billing/webhook-handler.ts +6 -2
- package/src/vercel/api/crud/create.ts +7 -2
- package/src/vercel/api/crud/delete.ts +3 -1
- package/src/vercel/api/crud/get.ts +3 -1
- package/src/vercel/api/crud/list.ts +3 -1
- package/src/vercel/api/crud/update.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donotdev/functions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Backend functions for DoNotDev Framework - Firebase, Vercel, and platform-agnostic implementations for auth, billing, CRUD, and OAuth",
|
|
6
6
|
"main": "./lib/firebase/index.js",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"serve": "firebase emulators:start --only functions"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@donotdev/core": "^0.0.
|
|
51
|
-
"@donotdev/firebase": "^0.0.
|
|
52
|
-
"@donotdev/supabase": "^0.0.
|
|
50
|
+
"@donotdev/core": "^0.0.26",
|
|
51
|
+
"@donotdev/firebase": "^0.0.13",
|
|
52
|
+
"@donotdev/supabase": "^0.0.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@sentry/node": "^10.39.0",
|
|
@@ -60,11 +60,19 @@ async function setCustomClaimsLogic(
|
|
|
60
60
|
|
|
61
61
|
// W17: Validate idempotency key to prevent oversized or malformed inputs.
|
|
62
62
|
if (idempotencyKey !== undefined) {
|
|
63
|
-
if (
|
|
64
|
-
|
|
63
|
+
if (
|
|
64
|
+
typeof idempotencyKey !== 'string' ||
|
|
65
|
+
idempotencyKey.length === 0 ||
|
|
66
|
+
idempotencyKey.length > 256
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'idempotencyKey must be a non-empty string of at most 256 characters'
|
|
70
|
+
);
|
|
65
71
|
}
|
|
66
72
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
67
|
-
throw new Error(
|
|
73
|
+
throw new Error(
|
|
74
|
+
'idempotencyKey contains invalid characters (allowed: alphanumeric, -, _, ., :, @)'
|
|
75
|
+
);
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
|
|
@@ -85,11 +93,17 @@ async function setCustomClaimsLogic(
|
|
|
85
93
|
alreadyProcessed = true;
|
|
86
94
|
return;
|
|
87
95
|
}
|
|
88
|
-
tx.set(idempotencyRef, {
|
|
96
|
+
tx.set(idempotencyRef, {
|
|
97
|
+
processing: true,
|
|
98
|
+
reservedAt: new Date().toISOString(),
|
|
99
|
+
});
|
|
89
100
|
});
|
|
90
101
|
|
|
91
102
|
if (alreadyProcessed) {
|
|
92
|
-
return existingResult as {
|
|
103
|
+
return existingResult as {
|
|
104
|
+
success: boolean;
|
|
105
|
+
customClaims: Record<string, any>;
|
|
106
|
+
};
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
109
|
|
|
@@ -21,7 +21,10 @@ import { FUNCTION_CONFIG } from './config/constants.js';
|
|
|
21
21
|
import { handleError } from '../shared/errorHandling.js';
|
|
22
22
|
import { assertAuthenticated, getUserRole } from '../shared/utils.js';
|
|
23
23
|
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
CallableRequest,
|
|
26
|
+
CallableOptions,
|
|
27
|
+
} from 'firebase-functions/v2/https';
|
|
25
28
|
|
|
26
29
|
// Optional monitoring imports - only used when enabled
|
|
27
30
|
// Lazy loaded to avoid unnecessary Firestore operations
|
|
@@ -76,9 +79,14 @@ async function loadMonitoring() {
|
|
|
76
79
|
function getClientIp(request: CallableRequest<unknown>): string {
|
|
77
80
|
const forwardedFor = request.rawRequest.headers['x-forwarded-for'];
|
|
78
81
|
if (forwardedFor) {
|
|
79
|
-
const raw = Array.isArray(forwardedFor)
|
|
82
|
+
const raw = Array.isArray(forwardedFor)
|
|
83
|
+
? forwardedFor.join(',')
|
|
84
|
+
: forwardedFor;
|
|
80
85
|
// Split and take the RIGHTMOST entry (last untrusted / first-to-be-trusted)
|
|
81
|
-
const ips = raw
|
|
86
|
+
const ips = raw
|
|
87
|
+
.split(',')
|
|
88
|
+
.map((s) => s.trim())
|
|
89
|
+
.filter(Boolean);
|
|
82
90
|
if (ips.length > 0) {
|
|
83
91
|
return ips[ips.length - 1]!;
|
|
84
92
|
}
|
|
@@ -17,7 +17,11 @@ import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
|
17
17
|
|
|
18
18
|
import { updateUserSubscription } from '../../shared/billing/helpers/updateUserSubscription.js';
|
|
19
19
|
import { handleError } from '../../shared/errorHandling.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
stripe,
|
|
22
|
+
validateStripeEnvironment,
|
|
23
|
+
initStripe,
|
|
24
|
+
} from '../../shared/utils.js';
|
|
21
25
|
import { createBaseFunction } from '../baseFunction.js';
|
|
22
26
|
import { STRIPE_CONFIG } from '../config/constants.js';
|
|
23
27
|
import { stripeSecretKey } from '../config/secrets.js';
|
|
@@ -70,10 +70,14 @@ async function createCustomerPortalLogic(
|
|
|
70
70
|
// Use caller-supplied returnUrl or derive from FRONTEND_URL env var.
|
|
71
71
|
const resolvedReturnUrl =
|
|
72
72
|
returnUrl ??
|
|
73
|
-
(process.env.FRONTEND_URL
|
|
73
|
+
(process.env.FRONTEND_URL
|
|
74
|
+
? `${process.env.FRONTEND_URL}/dashboard`
|
|
75
|
+
: undefined);
|
|
74
76
|
|
|
75
77
|
if (!resolvedReturnUrl) {
|
|
76
|
-
throw handleError(
|
|
78
|
+
throw handleError(
|
|
79
|
+
new Error('returnUrl is required (or set FRONTEND_URL env var)')
|
|
80
|
+
);
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
// Create portal session
|
|
@@ -102,7 +102,10 @@ export function createStripeWebhook(
|
|
|
102
102
|
async getUser(userId: string) {
|
|
103
103
|
return getFirebaseAdminAuth().getUser(userId);
|
|
104
104
|
},
|
|
105
|
-
async setCustomUserClaims(
|
|
105
|
+
async setCustomUserClaims(
|
|
106
|
+
userId: string,
|
|
107
|
+
claims: Record<string, unknown>
|
|
108
|
+
) {
|
|
106
109
|
await getFirebaseAdminAuth().setCustomUserClaims(userId, claims);
|
|
107
110
|
},
|
|
108
111
|
};
|
|
@@ -220,7 +220,11 @@ function aggregateEntitiesLogicFactory(
|
|
|
220
220
|
) {
|
|
221
221
|
return async function aggregateEntitiesLogic(
|
|
222
222
|
data: AggregateRequest,
|
|
223
|
-
context: {
|
|
223
|
+
context: {
|
|
224
|
+
uid: string;
|
|
225
|
+
userRole: UserRole;
|
|
226
|
+
request: CallableRequest<AggregateRequest>;
|
|
227
|
+
}
|
|
224
228
|
) {
|
|
225
229
|
const db = getFirebaseAdminFirestore();
|
|
226
230
|
const { userRole } = context;
|
|
@@ -160,11 +160,21 @@ function createEntityLogicFactory(
|
|
|
160
160
|
|
|
161
161
|
// W17: Validate idempotency key length and content.
|
|
162
162
|
if (idempotencyKey !== undefined) {
|
|
163
|
-
if (
|
|
164
|
-
|
|
163
|
+
if (
|
|
164
|
+
typeof idempotencyKey !== 'string' ||
|
|
165
|
+
idempotencyKey.length === 0 ||
|
|
166
|
+
idempotencyKey.length > 256
|
|
167
|
+
) {
|
|
168
|
+
throw new DoNotDevError(
|
|
169
|
+
'idempotencyKey must be a non-empty string of at most 256 characters',
|
|
170
|
+
'invalid-argument'
|
|
171
|
+
);
|
|
165
172
|
}
|
|
166
173
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
167
|
-
throw new DoNotDevError(
|
|
174
|
+
throw new DoNotDevError(
|
|
175
|
+
'idempotencyKey contains invalid characters',
|
|
176
|
+
'invalid-argument'
|
|
177
|
+
);
|
|
168
178
|
}
|
|
169
179
|
}
|
|
170
180
|
|
|
@@ -187,7 +197,10 @@ function createEntityLogicFactory(
|
|
|
187
197
|
alreadyProcessed = true;
|
|
188
198
|
return;
|
|
189
199
|
}
|
|
190
|
-
tx.set(idempotencyRef, {
|
|
200
|
+
tx.set(idempotencyRef, {
|
|
201
|
+
processing: true,
|
|
202
|
+
reservedAt: new Date().toISOString(),
|
|
203
|
+
});
|
|
191
204
|
});
|
|
192
205
|
|
|
193
206
|
if (alreadyProcessed) {
|
|
@@ -137,7 +137,11 @@ function listEntitiesLogicFactory(
|
|
|
137
137
|
// Apply where clauses for filtering — validate field names against entity schema
|
|
138
138
|
for (const [field, operator, value] of where) {
|
|
139
139
|
if (listFields && listFields.length > 0) {
|
|
140
|
-
if (
|
|
140
|
+
if (
|
|
141
|
+
!listFields.includes(field) &&
|
|
142
|
+
field !== 'status' &&
|
|
143
|
+
field !== 'id'
|
|
144
|
+
) {
|
|
141
145
|
throw new DoNotDevError(
|
|
142
146
|
`Where field '${field}' is not allowed`,
|
|
143
147
|
'invalid-argument'
|
|
@@ -173,9 +177,10 @@ function listEntitiesLogicFactory(
|
|
|
173
177
|
|
|
174
178
|
// W13: Cap at MAX_LIST_LIMIT to prevent unbounded reads (DoS via cost amplification).
|
|
175
179
|
const MAX_LIST_LIMIT = 500;
|
|
176
|
-
const effectiveLimit =
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
const effectiveLimit =
|
|
181
|
+
limit !== undefined && limit > 0
|
|
182
|
+
? Math.min(limit, MAX_LIST_LIMIT)
|
|
183
|
+
: MAX_LIST_LIMIT;
|
|
179
184
|
query = query.limit(effectiveLimit);
|
|
180
185
|
|
|
181
186
|
// Execute the query
|
|
@@ -153,11 +153,21 @@ function updateEntityLogicFactory(
|
|
|
153
153
|
|
|
154
154
|
// W17: Validate idempotency key length and content.
|
|
155
155
|
if (idempotencyKey !== undefined) {
|
|
156
|
-
if (
|
|
157
|
-
|
|
156
|
+
if (
|
|
157
|
+
typeof idempotencyKey !== 'string' ||
|
|
158
|
+
idempotencyKey.length === 0 ||
|
|
159
|
+
idempotencyKey.length > 256
|
|
160
|
+
) {
|
|
161
|
+
throw new DoNotDevError(
|
|
162
|
+
'idempotencyKey must be a non-empty string of at most 256 characters',
|
|
163
|
+
'invalid-argument'
|
|
164
|
+
);
|
|
158
165
|
}
|
|
159
166
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
160
|
-
throw new DoNotDevError(
|
|
167
|
+
throw new DoNotDevError(
|
|
168
|
+
'idempotencyKey contains invalid characters',
|
|
169
|
+
'invalid-argument'
|
|
170
|
+
);
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
|
|
@@ -178,7 +188,10 @@ function updateEntityLogicFactory(
|
|
|
178
188
|
alreadyProcessed = true;
|
|
179
189
|
return;
|
|
180
190
|
}
|
|
181
|
-
tx.set(idempotencyRef, {
|
|
191
|
+
tx.set(idempotencyRef, {
|
|
192
|
+
processing: true,
|
|
193
|
+
reservedAt: new Date().toISOString(),
|
|
194
|
+
});
|
|
182
195
|
});
|
|
183
196
|
|
|
184
197
|
if (alreadyProcessed) {
|
|
@@ -39,11 +39,21 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
|
|
|
39
39
|
|
|
40
40
|
// W17: Validate idempotency key length and content.
|
|
41
41
|
if (idempotencyKey !== undefined) {
|
|
42
|
-
if (
|
|
43
|
-
|
|
42
|
+
if (
|
|
43
|
+
typeof idempotencyKey !== 'string' ||
|
|
44
|
+
idempotencyKey.length === 0 ||
|
|
45
|
+
idempotencyKey.length > 256
|
|
46
|
+
) {
|
|
47
|
+
throw new HttpsError(
|
|
48
|
+
'invalid-argument',
|
|
49
|
+
'idempotencyKey must be a non-empty string of at most 256 characters'
|
|
50
|
+
);
|
|
44
51
|
}
|
|
45
52
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
46
|
-
throw new HttpsError(
|
|
53
|
+
throw new HttpsError(
|
|
54
|
+
'invalid-argument',
|
|
55
|
+
'idempotencyKey contains invalid characters'
|
|
56
|
+
);
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
|
|
@@ -67,7 +77,10 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
|
|
|
67
77
|
return;
|
|
68
78
|
}
|
|
69
79
|
// Reserve the key before executing business logic
|
|
70
|
-
tx.set(idempotencyRef, {
|
|
80
|
+
tx.set(idempotencyRef, {
|
|
81
|
+
processing: true,
|
|
82
|
+
reservedAt: new Date().toISOString(),
|
|
83
|
+
});
|
|
71
84
|
});
|
|
72
85
|
|
|
73
86
|
if (alreadyProcessed) {
|
|
@@ -108,7 +108,9 @@ describe('validateUrl', () => {
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
it('includes custom name in error message', () => {
|
|
111
|
-
expect(() => validateUrl('bad', 'Success URL')).toThrow(
|
|
111
|
+
expect(() => validateUrl('bad', 'Success URL')).toThrow(
|
|
112
|
+
'Invalid Success URL'
|
|
113
|
+
);
|
|
112
114
|
});
|
|
113
115
|
});
|
|
114
116
|
|
|
@@ -121,10 +123,10 @@ describe('validateMetadata', () => {
|
|
|
121
123
|
|
|
122
124
|
it('rejects non-string values', () => {
|
|
123
125
|
expect(() => validateMetadata({ key: 123 as any })).toThrow(
|
|
124
|
-
|
|
126
|
+
'must be a string'
|
|
125
127
|
);
|
|
126
128
|
expect(() => validateMetadata({ key: true as any })).toThrow(
|
|
127
|
-
|
|
129
|
+
'must be a string'
|
|
128
130
|
);
|
|
129
131
|
});
|
|
130
132
|
|
|
@@ -61,7 +61,9 @@ const BASE_REQUEST: CreateCheckoutSessionRequest = {
|
|
|
61
61
|
// Mock factories
|
|
62
62
|
// ---------------------------------------------------------------------------
|
|
63
63
|
|
|
64
|
-
function makeStripeProvider(
|
|
64
|
+
function makeStripeProvider(
|
|
65
|
+
overrides?: Partial<StripeCheckoutProvider>
|
|
66
|
+
): StripeCheckoutProvider {
|
|
65
67
|
return {
|
|
66
68
|
createCheckoutSession: vi.fn().mockResolvedValue({
|
|
67
69
|
id: 'cs_test_session123',
|
|
@@ -71,7 +73,9 @@ function makeStripeProvider(overrides?: Partial<StripeCheckoutProvider>): Stripe
|
|
|
71
73
|
};
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
function makeAuthProvider(
|
|
76
|
+
function makeAuthProvider(
|
|
77
|
+
overrides?: Partial<AuthCheckoutProvider>
|
|
78
|
+
): AuthCheckoutProvider {
|
|
75
79
|
return {
|
|
76
80
|
getUser: vi.fn().mockResolvedValue({ customClaims: {} }),
|
|
77
81
|
...overrides,
|
|
@@ -112,14 +116,24 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
112
116
|
});
|
|
113
117
|
|
|
114
118
|
it('verifies the user before creating the session', async () => {
|
|
115
|
-
await createCheckoutAlgorithm(
|
|
119
|
+
await createCheckoutAlgorithm(
|
|
120
|
+
BASE_REQUEST,
|
|
121
|
+
stripeProvider,
|
|
122
|
+
authProvider,
|
|
123
|
+
BILLING_CONFIG
|
|
124
|
+
);
|
|
116
125
|
|
|
117
126
|
expect(authProvider.getUser).toHaveBeenCalledOnce();
|
|
118
127
|
expect(authProvider.getUser).toHaveBeenCalledWith('user_001');
|
|
119
128
|
});
|
|
120
129
|
|
|
121
130
|
it('forwards priceId, mode, successUrl, cancelUrl to Stripe', async () => {
|
|
122
|
-
await createCheckoutAlgorithm(
|
|
131
|
+
await createCheckoutAlgorithm(
|
|
132
|
+
BASE_REQUEST,
|
|
133
|
+
stripeProvider,
|
|
134
|
+
authProvider,
|
|
135
|
+
BILLING_CONFIG
|
|
136
|
+
);
|
|
123
137
|
|
|
124
138
|
expect(stripeProvider.createCheckoutSession).toHaveBeenCalledWith(
|
|
125
139
|
expect.objectContaining({
|
|
@@ -132,7 +146,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
132
146
|
});
|
|
133
147
|
|
|
134
148
|
it('ensures userId and billingConfigKey are set in session metadata', async () => {
|
|
135
|
-
await createCheckoutAlgorithm(
|
|
149
|
+
await createCheckoutAlgorithm(
|
|
150
|
+
BASE_REQUEST,
|
|
151
|
+
stripeProvider,
|
|
152
|
+
authProvider,
|
|
153
|
+
BILLING_CONFIG
|
|
154
|
+
);
|
|
136
155
|
|
|
137
156
|
expect(stripeProvider.createCheckoutSession).toHaveBeenCalledWith(
|
|
138
157
|
expect.objectContaining({
|
|
@@ -150,7 +169,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
150
169
|
customerEmail: 'user@example.com',
|
|
151
170
|
};
|
|
152
171
|
|
|
153
|
-
await createCheckoutAlgorithm(
|
|
172
|
+
await createCheckoutAlgorithm(
|
|
173
|
+
request,
|
|
174
|
+
stripeProvider,
|
|
175
|
+
authProvider,
|
|
176
|
+
BILLING_CONFIG
|
|
177
|
+
);
|
|
154
178
|
|
|
155
179
|
expect(stripeProvider.createCheckoutSession).toHaveBeenCalledWith(
|
|
156
180
|
expect.objectContaining({ customerEmail: 'user@example.com' })
|
|
@@ -163,7 +187,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
163
187
|
allowPromotionCodes: undefined,
|
|
164
188
|
};
|
|
165
189
|
|
|
166
|
-
await createCheckoutAlgorithm(
|
|
190
|
+
await createCheckoutAlgorithm(
|
|
191
|
+
request,
|
|
192
|
+
stripeProvider,
|
|
193
|
+
authProvider,
|
|
194
|
+
BILLING_CONFIG
|
|
195
|
+
);
|
|
167
196
|
|
|
168
197
|
expect(stripeProvider.createCheckoutSession).toHaveBeenCalledWith(
|
|
169
198
|
expect.objectContaining({ allowPromotionCodes: true })
|
|
@@ -176,7 +205,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
176
205
|
mode: undefined,
|
|
177
206
|
};
|
|
178
207
|
|
|
179
|
-
await createCheckoutAlgorithm(
|
|
208
|
+
await createCheckoutAlgorithm(
|
|
209
|
+
request,
|
|
210
|
+
stripeProvider,
|
|
211
|
+
authProvider,
|
|
212
|
+
BILLING_CONFIG
|
|
213
|
+
);
|
|
180
214
|
|
|
181
215
|
expect(stripeProvider.createCheckoutSession).toHaveBeenCalledWith(
|
|
182
216
|
expect.objectContaining({ mode: 'payment' })
|
|
@@ -233,7 +267,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
233
267
|
};
|
|
234
268
|
|
|
235
269
|
await expect(
|
|
236
|
-
createCheckoutAlgorithm(
|
|
270
|
+
createCheckoutAlgorithm(
|
|
271
|
+
request,
|
|
272
|
+
stripeProvider,
|
|
273
|
+
authProvider,
|
|
274
|
+
BILLING_CONFIG
|
|
275
|
+
)
|
|
237
276
|
).rejects.toThrow('Missing billingConfigKey in metadata');
|
|
238
277
|
});
|
|
239
278
|
|
|
@@ -244,7 +283,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
244
283
|
};
|
|
245
284
|
|
|
246
285
|
await expect(
|
|
247
|
-
createCheckoutAlgorithm(
|
|
286
|
+
createCheckoutAlgorithm(
|
|
287
|
+
request,
|
|
288
|
+
stripeProvider,
|
|
289
|
+
authProvider,
|
|
290
|
+
BILLING_CONFIG
|
|
291
|
+
)
|
|
248
292
|
).rejects.toThrow('Missing billingConfigKey in metadata');
|
|
249
293
|
});
|
|
250
294
|
|
|
@@ -255,7 +299,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
255
299
|
};
|
|
256
300
|
|
|
257
301
|
await expect(
|
|
258
|
-
createCheckoutAlgorithm(
|
|
302
|
+
createCheckoutAlgorithm(
|
|
303
|
+
request,
|
|
304
|
+
stripeProvider,
|
|
305
|
+
authProvider,
|
|
306
|
+
BILLING_CONFIG
|
|
307
|
+
)
|
|
259
308
|
).rejects.toThrow();
|
|
260
309
|
|
|
261
310
|
expect(stripeProvider.createCheckoutSession).not.toHaveBeenCalled();
|
|
@@ -274,7 +323,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
274
323
|
};
|
|
275
324
|
|
|
276
325
|
await expect(
|
|
277
|
-
createCheckoutAlgorithm(
|
|
326
|
+
createCheckoutAlgorithm(
|
|
327
|
+
request,
|
|
328
|
+
stripeProvider,
|
|
329
|
+
authProvider,
|
|
330
|
+
BILLING_CONFIG
|
|
331
|
+
)
|
|
278
332
|
).rejects.toThrow('Invalid billing config key: nonexistent_plan');
|
|
279
333
|
});
|
|
280
334
|
|
|
@@ -286,7 +340,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
286
340
|
};
|
|
287
341
|
|
|
288
342
|
await expect(
|
|
289
|
-
createCheckoutAlgorithm(
|
|
343
|
+
createCheckoutAlgorithm(
|
|
344
|
+
request,
|
|
345
|
+
stripeProvider,
|
|
346
|
+
authProvider,
|
|
347
|
+
BILLING_CONFIG
|
|
348
|
+
)
|
|
290
349
|
).rejects.toThrow('Price ID mismatch with configuration');
|
|
291
350
|
});
|
|
292
351
|
|
|
@@ -298,7 +357,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
298
357
|
};
|
|
299
358
|
|
|
300
359
|
await expect(
|
|
301
|
-
createCheckoutAlgorithm(
|
|
360
|
+
createCheckoutAlgorithm(
|
|
361
|
+
request,
|
|
362
|
+
stripeProvider,
|
|
363
|
+
authProvider,
|
|
364
|
+
BILLING_CONFIG
|
|
365
|
+
)
|
|
302
366
|
).rejects.toThrow();
|
|
303
367
|
|
|
304
368
|
expect(stripeProvider.createCheckoutSession).not.toHaveBeenCalled();
|
|
@@ -311,7 +375,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
311
375
|
|
|
312
376
|
describe('auth validation', () => {
|
|
313
377
|
it('calls getUser with the request userId', async () => {
|
|
314
|
-
await createCheckoutAlgorithm(
|
|
378
|
+
await createCheckoutAlgorithm(
|
|
379
|
+
BASE_REQUEST,
|
|
380
|
+
stripeProvider,
|
|
381
|
+
authProvider,
|
|
382
|
+
BILLING_CONFIG
|
|
383
|
+
);
|
|
315
384
|
|
|
316
385
|
expect(authProvider.getUser).toHaveBeenCalledWith('user_001');
|
|
317
386
|
});
|
|
@@ -322,7 +391,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
322
391
|
});
|
|
323
392
|
|
|
324
393
|
await expect(
|
|
325
|
-
createCheckoutAlgorithm(
|
|
394
|
+
createCheckoutAlgorithm(
|
|
395
|
+
BASE_REQUEST,
|
|
396
|
+
stripeProvider,
|
|
397
|
+
authProvider,
|
|
398
|
+
BILLING_CONFIG
|
|
399
|
+
)
|
|
326
400
|
).rejects.toThrow('User not found');
|
|
327
401
|
});
|
|
328
402
|
|
|
@@ -332,7 +406,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
332
406
|
});
|
|
333
407
|
|
|
334
408
|
await expect(
|
|
335
|
-
createCheckoutAlgorithm(
|
|
409
|
+
createCheckoutAlgorithm(
|
|
410
|
+
BASE_REQUEST,
|
|
411
|
+
stripeProvider,
|
|
412
|
+
authProvider,
|
|
413
|
+
BILLING_CONFIG
|
|
414
|
+
)
|
|
336
415
|
).rejects.toThrow();
|
|
337
416
|
|
|
338
417
|
expect(stripeProvider.createCheckoutSession).not.toHaveBeenCalled();
|
|
@@ -346,11 +425,18 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
346
425
|
describe('Stripe error handling', () => {
|
|
347
426
|
it('propagates Stripe API errors', async () => {
|
|
348
427
|
stripeProvider = makeStripeProvider({
|
|
349
|
-
createCheckoutSession: vi
|
|
428
|
+
createCheckoutSession: vi
|
|
429
|
+
.fn()
|
|
430
|
+
.mockRejectedValue(new Error('Stripe network error')),
|
|
350
431
|
});
|
|
351
432
|
|
|
352
433
|
await expect(
|
|
353
|
-
createCheckoutAlgorithm(
|
|
434
|
+
createCheckoutAlgorithm(
|
|
435
|
+
BASE_REQUEST,
|
|
436
|
+
stripeProvider,
|
|
437
|
+
authProvider,
|
|
438
|
+
BILLING_CONFIG
|
|
439
|
+
)
|
|
354
440
|
).rejects.toThrow('Stripe network error');
|
|
355
441
|
});
|
|
356
442
|
|
|
@@ -362,7 +448,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
362
448
|
});
|
|
363
449
|
|
|
364
450
|
await expect(
|
|
365
|
-
createCheckoutAlgorithm(
|
|
451
|
+
createCheckoutAlgorithm(
|
|
452
|
+
BASE_REQUEST,
|
|
453
|
+
stripeProvider,
|
|
454
|
+
authProvider,
|
|
455
|
+
BILLING_CONFIG
|
|
456
|
+
)
|
|
366
457
|
).rejects.toThrow('Your card was declined');
|
|
367
458
|
});
|
|
368
459
|
|
|
@@ -374,7 +465,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
374
465
|
});
|
|
375
466
|
|
|
376
467
|
await expect(
|
|
377
|
-
createCheckoutAlgorithm(
|
|
468
|
+
createCheckoutAlgorithm(
|
|
469
|
+
BASE_REQUEST,
|
|
470
|
+
stripeProvider,
|
|
471
|
+
authProvider,
|
|
472
|
+
BILLING_CONFIG
|
|
473
|
+
)
|
|
378
474
|
).rejects.toThrow('Too many requests');
|
|
379
475
|
});
|
|
380
476
|
|
|
@@ -386,7 +482,12 @@ describe('createCheckoutAlgorithm', () => {
|
|
|
386
482
|
});
|
|
387
483
|
|
|
388
484
|
await expect(
|
|
389
|
-
createCheckoutAlgorithm(
|
|
485
|
+
createCheckoutAlgorithm(
|
|
486
|
+
BASE_REQUEST,
|
|
487
|
+
stripeProvider,
|
|
488
|
+
authProvider,
|
|
489
|
+
BILLING_CONFIG
|
|
490
|
+
)
|
|
390
491
|
).rejects.toBe(unexpected);
|
|
391
492
|
});
|
|
392
493
|
});
|