backend-manager 5.0.73 → 5.0.74
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/CLAUDE.md +70 -0
- package/README.md +81 -7
- package/package.json +1 -1
- package/src/manager/cron/daily/reset-usage.js +5 -32
- package/src/manager/events/firestore/payments-webhooks/on-write.js +126 -0
- package/src/manager/functions/core/actions/api/admin/get-stats.js +3 -3
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +1 -1
- package/src/manager/functions/core/actions/api/user/delete.js +5 -3
- package/src/manager/functions/core/actions/api/user/get-subscription-info.js +25 -9
- package/src/manager/functions/core/actions/api/user/validate-settings.js +1 -1
- package/src/manager/helpers/analytics.js +4 -4
- package/src/manager/helpers/api-manager.js +25 -42
- package/src/manager/helpers/middleware.js +1 -1
- package/src/manager/helpers/usage.js +24 -93
- package/src/manager/helpers/user.js +29 -38
- package/src/manager/index.js +22 -10
- package/src/manager/libraries/stripe.js +293 -0
- package/src/manager/routes/admin/stats/get.js +3 -3
- package/src/manager/routes/marketing/contact/post.js +1 -1
- package/src/manager/routes/payments/intent/post.js +94 -0
- package/src/manager/routes/payments/intent/providers/stripe.js +66 -0
- package/src/manager/routes/payments/webhook/post.js +87 -0
- package/src/manager/routes/payments/webhook/providers/stripe.js +35 -0
- package/src/manager/routes/test/schema/post.js +5 -5
- package/src/manager/routes/user/delete.js +5 -3
- package/src/manager/routes/user/settings/validate/post.js +3 -3
- package/src/manager/routes/user/subscription/get.js +25 -9
- package/src/manager/schemas/payments/intent/post.js +22 -0
- package/src/manager/schemas/payments/webhook/post.js +6 -0
- package/src/manager/schemas/test/schema/post.js +1 -1
- package/src/test/test-accounts.js +63 -25
- package/src/test/utils/firestore-rules-client.js +5 -5
- package/templates/backend-manager-config.json +32 -0
- package/templates/firestore.rules +1 -1
- package/test/_init/accounts-validation.js +3 -3
- package/test/functions/user/delete.js +1 -1
- package/test/functions/user/get-subscription-info.js +18 -24
- package/test/payments/intent.js +104 -0
- package/test/payments/journey-payment-cancel.js +166 -0
- package/test/payments/journey-payment-suspend.js +162 -0
- package/test/payments/journey-payment-trial.js +167 -0
- package/test/payments/journey-payment-upgrade.js +136 -0
- package/test/payments/webhook.js +128 -0
- package/test/routes/test/schema.js +1 -1
- package/test/routes/user/delete.js +1 -1
- package/test/routes/user/subscription.js +18 -24
- package/test/routes/user/user.js +14 -14
- package/test/rules/user.js +8 -8
- package/src/manager/helpers/subscription-resolver-new.js +0 -827
- package/src/manager/helpers/subscription-resolver.js +0 -841
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const powertools = require('node-powertools');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* POST /payments/intent
|
|
6
|
+
* Creates a payment intent (e.g., Stripe Checkout Session) for subscription purchase
|
|
7
|
+
* Requires authentication
|
|
8
|
+
*/
|
|
9
|
+
module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
10
|
+
const { admin } = libraries;
|
|
11
|
+
|
|
12
|
+
// Require authentication
|
|
13
|
+
if (!user.authenticated) {
|
|
14
|
+
return assistant.respond('Authentication required', { code: 401 });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const uid = user.auth.uid;
|
|
18
|
+
const processor = settings.processor;
|
|
19
|
+
const productId = settings.productId;
|
|
20
|
+
const frequency = settings.frequency;
|
|
21
|
+
const trial = settings.trial;
|
|
22
|
+
|
|
23
|
+
// Check if user already has an active non-basic subscription
|
|
24
|
+
if (user.subscription?.status === 'active' && user.subscription?.product?.id !== 'basic') {
|
|
25
|
+
return assistant.respond('User already has an active subscription', { code: 400 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check trial eligibility
|
|
29
|
+
if (trial && user.subscription?.trial?.activated) {
|
|
30
|
+
return assistant.respond('Trial already used', { code: 400 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Validate product exists in config
|
|
34
|
+
const product = (Manager.config.products || []).find(p => p.id === productId);
|
|
35
|
+
if (!product) {
|
|
36
|
+
return assistant.respond(`Product '${productId}' not found`, { code: 400 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Load the provider
|
|
40
|
+
let provider;
|
|
41
|
+
try {
|
|
42
|
+
provider = require(path.resolve(__dirname, `providers/${processor}.js`));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Initialize the processor SDK
|
|
48
|
+
const StripeLib = require('../../../libraries/stripe.js');
|
|
49
|
+
const stripe = StripeLib.init();
|
|
50
|
+
|
|
51
|
+
// Create the intent via the provider
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await provider.createIntent({
|
|
55
|
+
uid,
|
|
56
|
+
productId,
|
|
57
|
+
frequency,
|
|
58
|
+
trial,
|
|
59
|
+
config: Manager.config,
|
|
60
|
+
stripe,
|
|
61
|
+
});
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return assistant.respond(`Failed to create intent: ${e.message}`, { code: 500, sentry: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build timestamps
|
|
67
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
68
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
69
|
+
|
|
70
|
+
// Save to payments-intents collection
|
|
71
|
+
await admin.firestore().doc(`payments-intents/${result.id}`).set({
|
|
72
|
+
id: result.id,
|
|
73
|
+
processor: processor,
|
|
74
|
+
uid: uid,
|
|
75
|
+
status: 'pending',
|
|
76
|
+
productId: productId,
|
|
77
|
+
frequency: frequency,
|
|
78
|
+
trial: trial,
|
|
79
|
+
raw: result.raw,
|
|
80
|
+
metadata: {
|
|
81
|
+
created: {
|
|
82
|
+
timestamp: now,
|
|
83
|
+
timestampUNIX: nowUNIX,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
assistant.log(`Intent ${result.id} created for user ${uid} (product=${productId}, frequency=${frequency}, trial=${trial})`);
|
|
89
|
+
|
|
90
|
+
return assistant.respond({
|
|
91
|
+
id: result.id,
|
|
92
|
+
url: result.url,
|
|
93
|
+
});
|
|
94
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe intent provider
|
|
3
|
+
* Creates Stripe Checkout Sessions for subscription purchases
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
/**
|
|
7
|
+
* Create a Stripe Checkout Session for a subscription
|
|
8
|
+
*
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {string} options.uid - User's UID
|
|
11
|
+
* @param {string} options.productId - Product ID from config (e.g., 'premium')
|
|
12
|
+
* @param {string} options.frequency - 'monthly' or 'annually'
|
|
13
|
+
* @param {boolean} options.trial - Whether to include a trial period
|
|
14
|
+
* @param {object} options.config - BEM config (must contain products array)
|
|
15
|
+
* @param {object} options.stripe - Initialized Stripe SDK instance
|
|
16
|
+
* @returns {object} { id, url, raw }
|
|
17
|
+
*/
|
|
18
|
+
async createIntent({ uid, productId, frequency, trial, config, stripe }) {
|
|
19
|
+
// Find the product in config
|
|
20
|
+
const product = (config.products || []).find(p => p.id === productId);
|
|
21
|
+
if (!product) {
|
|
22
|
+
throw new Error(`Product '${productId}' not found in config`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Get the Stripe price ID for the requested frequency
|
|
26
|
+
const priceId = product.prices?.[frequency]?.stripe;
|
|
27
|
+
if (!priceId) {
|
|
28
|
+
throw new Error(`No Stripe price found for ${productId}/${frequency}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build session params
|
|
32
|
+
const sessionParams = {
|
|
33
|
+
mode: 'subscription',
|
|
34
|
+
line_items: [{
|
|
35
|
+
price: priceId,
|
|
36
|
+
quantity: 1,
|
|
37
|
+
}],
|
|
38
|
+
subscription_data: {
|
|
39
|
+
metadata: {
|
|
40
|
+
uid: uid,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
success_url: `${config.brand?.url || 'https://example.com'}/account/?payment=success`,
|
|
44
|
+
cancel_url: `${config.brand?.url || 'https://example.com'}/account/?payment=cancelled`,
|
|
45
|
+
metadata: {
|
|
46
|
+
uid: uid,
|
|
47
|
+
productId: productId,
|
|
48
|
+
frequency: frequency,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Add trial period if requested
|
|
53
|
+
if (trial && product.trial?.days) {
|
|
54
|
+
sessionParams.subscription_data.trial_period_days = product.trial.days;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create the checkout session
|
|
58
|
+
const session = await stripe.checkout.sessions.create(sessionParams);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: session.id,
|
|
62
|
+
url: session.url,
|
|
63
|
+
raw: session,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const powertools = require('node-powertools');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* POST /payments/webhook?processor=stripe&key=XXX
|
|
6
|
+
* Receives payment provider webhooks, validates them, and saves to Firestore
|
|
7
|
+
* The Firestore onWrite trigger handles async processing
|
|
8
|
+
*/
|
|
9
|
+
module.exports = async ({ assistant, Manager, libraries }) => {
|
|
10
|
+
const { admin } = libraries;
|
|
11
|
+
const data = assistant.request.data;
|
|
12
|
+
const query = assistant.request.query;
|
|
13
|
+
|
|
14
|
+
// Get processor and key from query params
|
|
15
|
+
const processor = query.processor;
|
|
16
|
+
const key = query.key;
|
|
17
|
+
|
|
18
|
+
// Validate processor
|
|
19
|
+
if (!processor) {
|
|
20
|
+
return assistant.respond('Missing processor parameter', { code: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Validate key against BACKEND_MANAGER_KEY
|
|
24
|
+
if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
|
|
25
|
+
return assistant.respond('Invalid key', { code: 401 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Load the provider
|
|
29
|
+
let provider;
|
|
30
|
+
try {
|
|
31
|
+
provider = require(path.resolve(__dirname, `providers/${processor}.js`));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Parse the webhook using the provider
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = provider.parseWebhook(assistant.ref.req);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return assistant.respond(`Failed to parse webhook: ${e.message}`, { code: 400 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { eventId, eventType, raw, uid } = parsed;
|
|
45
|
+
|
|
46
|
+
// Check for duplicate (skip if already processing/completed)
|
|
47
|
+
const existingDoc = await admin.firestore().doc(`payments-webhooks/${eventId}`).get();
|
|
48
|
+
if (existingDoc.exists) {
|
|
49
|
+
const existingStatus = existingDoc.data()?.status;
|
|
50
|
+
if (existingStatus !== 'failed') {
|
|
51
|
+
assistant.log(`Webhook ${eventId} already exists with status=${existingStatus}, skipping`);
|
|
52
|
+
return assistant.respond({ received: true, duplicate: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build timestamps
|
|
57
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
58
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
59
|
+
|
|
60
|
+
// Save to Firestore with status=pending (trigger handles the rest)
|
|
61
|
+
await admin.firestore().doc(`payments-webhooks/${eventId}`).set({
|
|
62
|
+
id: eventId,
|
|
63
|
+
processor: processor,
|
|
64
|
+
status: 'pending',
|
|
65
|
+
raw: raw,
|
|
66
|
+
uid: uid,
|
|
67
|
+
event: {
|
|
68
|
+
type: eventType,
|
|
69
|
+
},
|
|
70
|
+
error: null,
|
|
71
|
+
metadata: {
|
|
72
|
+
received: {
|
|
73
|
+
timestamp: now,
|
|
74
|
+
timestampUNIX: nowUNIX,
|
|
75
|
+
},
|
|
76
|
+
processed: {
|
|
77
|
+
timestamp: null,
|
|
78
|
+
timestampUNIX: null,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assistant.log(`Webhook ${eventId} saved (type=${eventType}, processor=${processor}, uid=${uid})`);
|
|
84
|
+
|
|
85
|
+
// Return 200 immediately
|
|
86
|
+
return assistant.respond({ received: true });
|
|
87
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe webhook provider
|
|
3
|
+
* Extracts and validates webhook event data from Stripe
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
/**
|
|
7
|
+
* Parse a Stripe webhook request
|
|
8
|
+
* Extracts the event data, event type, and resolves the UID from metadata
|
|
9
|
+
*
|
|
10
|
+
* @param {object} req - The raw HTTP request
|
|
11
|
+
* @returns {object} { eventId, eventType, raw, uid }
|
|
12
|
+
*/
|
|
13
|
+
parseWebhook(req) {
|
|
14
|
+
const event = req.body;
|
|
15
|
+
|
|
16
|
+
// Validate event structure
|
|
17
|
+
if (!event || !event.id || !event.type) {
|
|
18
|
+
throw new Error('Invalid Stripe webhook payload');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The subscription object is typically in event.data.object
|
|
22
|
+
const dataObject = event.data?.object || {};
|
|
23
|
+
|
|
24
|
+
// Resolve UID from subscription metadata
|
|
25
|
+
// When creating checkout sessions, we set metadata.uid on the subscription
|
|
26
|
+
const uid = dataObject.metadata?.uid || null;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
eventId: event.id,
|
|
30
|
+
eventType: event.type,
|
|
31
|
+
raw: event,
|
|
32
|
+
uid: uid,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Returns the resolved settings for testing purposes
|
|
4
4
|
*/
|
|
5
5
|
module.exports = async ({ assistant, user, settings }) => {
|
|
6
|
-
assistant.log('test/schema: User
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
assistant.log('test/schema: User subscription info', {
|
|
7
|
+
subscriptionId: user.subscription?.product?.id,
|
|
8
|
+
subscriptionStatus: user.subscription?.status,
|
|
9
|
+
fullSubscription: user.subscription,
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
return assistant.respond({
|
|
@@ -14,7 +14,7 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
14
14
|
user: {
|
|
15
15
|
authenticated: user.authenticated,
|
|
16
16
|
uid: user.auth?.uid || null,
|
|
17
|
-
|
|
17
|
+
subscription: user.subscription?.product?.id || 'basic',
|
|
18
18
|
},
|
|
19
19
|
});
|
|
20
20
|
};
|
|
@@ -29,10 +29,12 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
29
29
|
|
|
30
30
|
const userData = userDoc.data();
|
|
31
31
|
|
|
32
|
-
// Disallow deleting users with active subscriptions
|
|
32
|
+
// Disallow deleting users with active or suspended paid subscriptions
|
|
33
|
+
const subStatus = userData?.subscription?.status;
|
|
34
|
+
const subId = userData?.subscription?.product?.id;
|
|
33
35
|
if (
|
|
34
|
-
(
|
|
35
|
-
|
|
36
|
+
(subStatus === 'active' || subStatus === 'suspended')
|
|
37
|
+
&& subId !== 'basic'
|
|
36
38
|
) {
|
|
37
39
|
return assistant.respond(
|
|
38
40
|
'This account cannot be deleted because it has a paid subscription attached to it. In order to delete the account, you must first cancel the paid subscription.',
|
|
@@ -5,7 +5,7 @@ const path = require('path');
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* POST /user/settings/validate - Validate user settings against defaults
|
|
8
|
-
* Merges user settings with
|
|
8
|
+
* Merges user settings with subscription-specific defaults from defaults.js
|
|
9
9
|
*/
|
|
10
10
|
module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
11
11
|
const { admin } = libraries;
|
|
@@ -23,7 +23,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
23
23
|
return assistant.respond('Admin required', { code: 403 });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// Get user data for
|
|
26
|
+
// Get user data for subscription
|
|
27
27
|
let userData = user;
|
|
28
28
|
|
|
29
29
|
if (uid !== user.auth.uid) {
|
|
@@ -50,7 +50,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
50
50
|
// Load and process defaults
|
|
51
51
|
try {
|
|
52
52
|
const defaults = _.get(require(resolvedPath)(), settings.defaultsPath);
|
|
53
|
-
const combined = combineDefaults(defaults.all, defaults[userData.
|
|
53
|
+
const combined = combineDefaults(defaults.all, defaults[userData.subscription?.product?.id] || {});
|
|
54
54
|
|
|
55
55
|
assistant.log('Combined settings', combined);
|
|
56
56
|
|
|
@@ -2,7 +2,7 @@ const powertools = require('node-powertools');
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* GET /user/subscription - Get user subscription info
|
|
5
|
-
* Returns
|
|
5
|
+
* Returns subscription, expiry, trial, and payment status
|
|
6
6
|
*/
|
|
7
7
|
module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
8
8
|
const { admin } = libraries;
|
|
@@ -38,21 +38,37 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
38
38
|
const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
|
|
39
39
|
|
|
40
40
|
const result = {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
subscription: {
|
|
42
|
+
product: {
|
|
43
|
+
id: userData?.subscription?.product?.id || 'basic',
|
|
44
|
+
name: userData?.subscription?.product?.name || 'Basic',
|
|
45
|
+
},
|
|
46
|
+
status: userData?.subscription?.status || 'active',
|
|
43
47
|
expires: {
|
|
44
|
-
timestamp: userData?.
|
|
45
|
-
timestampUNIX: userData?.
|
|
48
|
+
timestamp: userData?.subscription?.expires?.timestamp || oldDate,
|
|
49
|
+
timestampUNIX: userData?.subscription?.expires?.timestampUNIX || oldDateUNIX,
|
|
46
50
|
},
|
|
47
51
|
trial: {
|
|
48
|
-
activated: userData?.
|
|
52
|
+
activated: userData?.subscription?.trial?.activated ?? false,
|
|
53
|
+
expires: {
|
|
54
|
+
timestamp: userData?.subscription?.trial?.expires?.timestamp || oldDate,
|
|
55
|
+
timestampUNIX: userData?.subscription?.trial?.expires?.timestampUNIX || oldDateUNIX,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
cancellation: {
|
|
59
|
+
pending: userData?.subscription?.cancellation?.pending ?? false,
|
|
49
60
|
date: {
|
|
50
|
-
timestamp: userData?.
|
|
51
|
-
timestampUNIX: userData?.
|
|
61
|
+
timestamp: userData?.subscription?.cancellation?.date?.timestamp || oldDate,
|
|
62
|
+
timestampUNIX: userData?.subscription?.cancellation?.date?.timestampUNIX || oldDateUNIX,
|
|
52
63
|
},
|
|
53
64
|
},
|
|
54
65
|
payment: {
|
|
55
|
-
|
|
66
|
+
processor: userData?.subscription?.payment?.processor || null,
|
|
67
|
+
frequency: userData?.subscription?.payment?.frequency || null,
|
|
68
|
+
startDate: {
|
|
69
|
+
timestamp: userData?.subscription?.payment?.startDate?.timestamp || oldDate,
|
|
70
|
+
timestampUNIX: userData?.subscription?.payment?.startDate?.timestampUNIX || oldDateUNIX,
|
|
71
|
+
},
|
|
56
72
|
},
|
|
57
73
|
},
|
|
58
74
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema: POST /payments/intent
|
|
3
|
+
* Validates intent creation parameters
|
|
4
|
+
*/
|
|
5
|
+
module.exports = () => ({
|
|
6
|
+
processor: {
|
|
7
|
+
types: ['string'],
|
|
8
|
+
required: true,
|
|
9
|
+
},
|
|
10
|
+
productId: {
|
|
11
|
+
types: ['string'],
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
frequency: {
|
|
15
|
+
types: ['string'],
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
trial: {
|
|
19
|
+
types: ['boolean'],
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Tests: types, default (static + function), value, min, max, required, clean
|
|
4
4
|
*/
|
|
5
5
|
module.exports = ({ user }) => {
|
|
6
|
-
const planId = user?.
|
|
6
|
+
const planId = user?.subscription?.product?.id || 'basic';
|
|
7
7
|
const isPremium = planId !== 'basic';
|
|
8
8
|
|
|
9
9
|
const schema = {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
const uuid = require('uuid');
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Helper to create a future expiration date for premium
|
|
5
|
-
* resolve-account checks
|
|
6
|
-
* If expires is in the past (or default 1970),
|
|
4
|
+
* Helper to create a future expiration date for premium subscriptions
|
|
5
|
+
* resolve-account checks subscription.expires to determine if subscription is active
|
|
6
|
+
* If expires is in the past (or default 1970), subscription gets downgraded to basic
|
|
7
7
|
*/
|
|
8
8
|
function getFutureExpires(years = 10) {
|
|
9
9
|
const futureDate = new Date();
|
|
@@ -15,7 +15,7 @@ function getFutureExpires(years = 10) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Helper to create a past expiration date for expired
|
|
18
|
+
* Helper to create a past expiration date for expired subscriptions
|
|
19
19
|
*/
|
|
20
20
|
function getPastExpires(years = 1) {
|
|
21
21
|
const pastDate = new Date();
|
|
@@ -37,7 +37,7 @@ function getPastExpires(years = 1) {
|
|
|
37
37
|
* - email: Email with {domain} placeholder (resolved at runtime)
|
|
38
38
|
* - properties: Object to merge into user doc after auth:on-create
|
|
39
39
|
*
|
|
40
|
-
* IMPORTANT: Premium accounts MUST have
|
|
40
|
+
* IMPORTANT: Premium accounts MUST have subscription.expires set to a future date
|
|
41
41
|
* or resolve-account will downgrade them to basic
|
|
42
42
|
*/
|
|
43
43
|
const STATIC_ACCOUNTS = {
|
|
@@ -47,7 +47,7 @@ const STATIC_ACCOUNTS = {
|
|
|
47
47
|
email: '_test.admin@{domain}',
|
|
48
48
|
properties: {
|
|
49
49
|
roles: { admin: true },
|
|
50
|
-
|
|
50
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
53
|
basic: {
|
|
@@ -56,7 +56,7 @@ const STATIC_ACCOUNTS = {
|
|
|
56
56
|
email: '_test.basic@{domain}',
|
|
57
57
|
properties: {
|
|
58
58
|
roles: {},
|
|
59
|
-
|
|
59
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
62
|
'premium-active': {
|
|
@@ -65,7 +65,7 @@ const STATIC_ACCOUNTS = {
|
|
|
65
65
|
email: '_test.premium-active@{domain}',
|
|
66
66
|
properties: {
|
|
67
67
|
roles: {},
|
|
68
|
-
|
|
68
|
+
subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() },
|
|
69
69
|
},
|
|
70
70
|
},
|
|
71
71
|
'premium-expired': {
|
|
@@ -74,7 +74,7 @@ const STATIC_ACCOUNTS = {
|
|
|
74
74
|
email: '_test.premium-expired@{domain}',
|
|
75
75
|
properties: {
|
|
76
76
|
roles: {},
|
|
77
|
-
|
|
77
|
+
subscription: { product: { id: 'premium' }, status: 'cancelled', expires: getPastExpires() },
|
|
78
78
|
},
|
|
79
79
|
},
|
|
80
80
|
delete: {
|
|
@@ -83,7 +83,7 @@ const STATIC_ACCOUNTS = {
|
|
|
83
83
|
email: '_test.delete@{domain}',
|
|
84
84
|
properties: {
|
|
85
85
|
roles: {},
|
|
86
|
-
|
|
86
|
+
subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Active subscription - deletion should be blocked initially
|
|
87
87
|
},
|
|
88
88
|
},
|
|
89
89
|
'delete-by-admin': {
|
|
@@ -92,7 +92,7 @@ const STATIC_ACCOUNTS = {
|
|
|
92
92
|
email: '_test.delete-by-admin@{domain}',
|
|
93
93
|
properties: {
|
|
94
94
|
roles: {},
|
|
95
|
-
// No
|
|
95
|
+
// No subscription - can be deleted immediately by admin
|
|
96
96
|
},
|
|
97
97
|
},
|
|
98
98
|
referrer: {
|
|
@@ -101,7 +101,7 @@ const STATIC_ACCOUNTS = {
|
|
|
101
101
|
email: '_test.referrer@{domain}',
|
|
102
102
|
properties: {
|
|
103
103
|
roles: {},
|
|
104
|
-
|
|
104
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
105
105
|
affiliate: { code: 'TESTREF', referrals: [] },
|
|
106
106
|
},
|
|
107
107
|
},
|
|
@@ -111,7 +111,7 @@ const STATIC_ACCOUNTS = {
|
|
|
111
111
|
email: '_test.referred@{domain}',
|
|
112
112
|
properties: {
|
|
113
113
|
roles: {},
|
|
114
|
-
|
|
114
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
115
115
|
},
|
|
116
116
|
},
|
|
117
117
|
'referred-invalid': {
|
|
@@ -120,7 +120,7 @@ const STATIC_ACCOUNTS = {
|
|
|
120
120
|
email: '_test.referred-invalid@{domain}',
|
|
121
121
|
properties: {
|
|
122
122
|
roles: {},
|
|
123
|
-
|
|
123
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
124
124
|
},
|
|
125
125
|
},
|
|
126
126
|
};
|
|
@@ -130,22 +130,40 @@ const STATIC_ACCOUNTS = {
|
|
|
130
130
|
* These accounts transition through states via webhook tests
|
|
131
131
|
*/
|
|
132
132
|
const JOURNEY_ACCOUNTS = {
|
|
133
|
-
'journey-upgrade': {
|
|
134
|
-
id: 'journey-upgrade',
|
|
135
|
-
uid: '_test-journey-upgrade',
|
|
136
|
-
email: '_test.journey-upgrade@{domain}',
|
|
133
|
+
'journey-payment-upgrade': {
|
|
134
|
+
id: 'journey-payment-upgrade',
|
|
135
|
+
uid: '_test-journey-payment-upgrade',
|
|
136
|
+
email: '_test.journey-payment-upgrade@{domain}',
|
|
137
137
|
properties: {
|
|
138
138
|
roles: {},
|
|
139
|
-
|
|
139
|
+
subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via Stripe webhook
|
|
140
140
|
},
|
|
141
141
|
},
|
|
142
|
-
'journey-cancel': {
|
|
143
|
-
id: 'journey-cancel',
|
|
144
|
-
uid: '_test-journey-cancel',
|
|
145
|
-
email: '_test.journey-cancel@{domain}',
|
|
142
|
+
'journey-payment-cancel': {
|
|
143
|
+
id: 'journey-payment-cancel',
|
|
144
|
+
uid: '_test-journey-payment-cancel',
|
|
145
|
+
email: '_test.journey-payment-cancel@{domain}',
|
|
146
146
|
properties: {
|
|
147
147
|
roles: {},
|
|
148
|
-
|
|
148
|
+
subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Starts as premium, cancelled via Stripe webhook
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
'journey-payment-suspend': {
|
|
152
|
+
id: 'journey-payment-suspend',
|
|
153
|
+
uid: '_test-journey-payment-suspend',
|
|
154
|
+
email: '_test.journey-payment-suspend@{domain}',
|
|
155
|
+
properties: {
|
|
156
|
+
roles: {},
|
|
157
|
+
subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Starts as premium, suspended via failed payment webhook
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
'journey-payment-trial': {
|
|
161
|
+
id: 'journey-payment-trial',
|
|
162
|
+
uid: '_test-journey-payment-trial',
|
|
163
|
+
email: '_test.journey-payment-trial@{domain}',
|
|
164
|
+
properties: {
|
|
165
|
+
roles: {},
|
|
166
|
+
subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via trial webhook
|
|
149
167
|
},
|
|
150
168
|
},
|
|
151
169
|
};
|
|
@@ -275,7 +293,7 @@ async function createAccount(admin, account) {
|
|
|
275
293
|
waited += pollInterval;
|
|
276
294
|
}
|
|
277
295
|
|
|
278
|
-
// Merge test-specific properties (roles,
|
|
296
|
+
// Merge test-specific properties (roles, subscription, etc.)
|
|
279
297
|
await userRef.set(account.properties, { merge: true });
|
|
280
298
|
|
|
281
299
|
return { uid: account.uid, email: account.email };
|
|
@@ -334,6 +352,26 @@ async function deleteTestUsers(admin) {
|
|
|
334
352
|
})
|
|
335
353
|
);
|
|
336
354
|
|
|
355
|
+
// Clean up payment-related collections for test accounts
|
|
356
|
+
const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
|
|
357
|
+
const paymentCollections = ['payments-subscriptions', 'payments-webhooks', 'payments-intents'];
|
|
358
|
+
|
|
359
|
+
await Promise.all(
|
|
360
|
+
paymentCollections.map(async (collection) => {
|
|
361
|
+
try {
|
|
362
|
+
const snapshot = await admin.firestore().collection(collection)
|
|
363
|
+
.where('uid', 'in', testUids)
|
|
364
|
+
.get();
|
|
365
|
+
|
|
366
|
+
await Promise.all(
|
|
367
|
+
snapshot.docs.map(doc => doc.ref.delete())
|
|
368
|
+
);
|
|
369
|
+
} catch (e) {
|
|
370
|
+
// Collection may not exist yet — ignore
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
|
|
337
375
|
return {
|
|
338
376
|
success: results.failed.length === 0,
|
|
339
377
|
deleted: results.deleted.length,
|
|
@@ -162,7 +162,7 @@ async function seedTestAccounts(accounts) {
|
|
|
162
162
|
throw new Error('Test environment not initialized. Call initRulesTestEnv() first.');
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
// Get static account definitions for roles/
|
|
165
|
+
// Get static account definitions for roles/subscription data
|
|
166
166
|
const { TEST_ACCOUNTS } = require('../test-accounts.js');
|
|
167
167
|
|
|
168
168
|
// Use withSecurityRulesDisabled to write test data
|
|
@@ -174,7 +174,7 @@ async function seedTestAccounts(accounts) {
|
|
|
174
174
|
continue;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// Get the static definition for this account type (has roles,
|
|
177
|
+
// Get the static definition for this account type (has roles, subscription)
|
|
178
178
|
const staticDef = TEST_ACCOUNTS[accountType];
|
|
179
179
|
|
|
180
180
|
// Build user document with roles for isAdmin() check
|
|
@@ -186,9 +186,9 @@ async function seedTestAccounts(accounts) {
|
|
|
186
186
|
roles: staticDef?.properties?.roles || {},
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
-
// Add
|
|
190
|
-
if (staticDef?.properties?.
|
|
191
|
-
userData.
|
|
189
|
+
// Add subscription if present in static definition
|
|
190
|
+
if (staticDef?.properties?.subscription) {
|
|
191
|
+
userData.subscription = staticDef.properties.subscription;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
await db.doc(`users/${account.uid}`).set(userData, { merge: true });
|