backend-manager 5.0.72 → 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 +83 -9
- 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 +2 -2
- 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
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Usage
|
|
3
3
|
* Meant to check and update usage for a user
|
|
4
|
-
*
|
|
4
|
+
* Reads product limits from Manager.config.products
|
|
5
5
|
* Stores usage in the user's firestore document OR in local/temp storage if no user
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const fetch = require('wonderful-fetch');
|
|
9
8
|
const moment = require('moment');
|
|
10
9
|
const _ = require('lodash');
|
|
11
10
|
const hcaptcha = require('hcaptcha');
|
|
@@ -16,14 +15,12 @@ function Usage(m) {
|
|
|
16
15
|
self.Manager = m;
|
|
17
16
|
|
|
18
17
|
self.user = null;
|
|
19
|
-
self.app = null;
|
|
20
18
|
self.options = null;
|
|
21
19
|
self.assistant = null;
|
|
22
20
|
self.storage = null;
|
|
23
21
|
|
|
24
22
|
self.paths = {
|
|
25
23
|
user: '',
|
|
26
|
-
app: '',
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
self.initialized = false;
|
|
@@ -37,8 +34,6 @@ Usage.prototype.init = function (assistant, options) {
|
|
|
37
34
|
|
|
38
35
|
// Set options
|
|
39
36
|
options = options || {};
|
|
40
|
-
options.app = options.app || Manager.config.app.id;
|
|
41
|
-
options.refetch = typeof options.refetch === 'undefined' ? false : options.refetch;
|
|
42
37
|
options.clear = typeof options.clear === 'undefined' ? false : options.clear;
|
|
43
38
|
options.today = typeof options.today === 'undefined' ? undefined : options.today;
|
|
44
39
|
options.key = typeof options.key === 'undefined' ? undefined : options.key;
|
|
@@ -60,7 +55,7 @@ Usage.prototype.init = function (assistant, options) {
|
|
|
60
55
|
// Set assistant
|
|
61
56
|
self.assistant = assistant;
|
|
62
57
|
|
|
63
|
-
// Setup storage
|
|
58
|
+
// Setup storage (used for unauthenticated local-mode usage tracking)
|
|
64
59
|
self.storage = Manager.storage({name: 'usage', temporary: true, clear: options.clear, log: options.log});
|
|
65
60
|
|
|
66
61
|
// Set local key
|
|
@@ -69,11 +64,6 @@ Usage.prototype.init = function (assistant, options) {
|
|
|
69
64
|
|
|
70
65
|
// Set paths
|
|
71
66
|
self.paths.user = `users.${self.key}`;
|
|
72
|
-
self.paths.app = `apps.${options.app}`;
|
|
73
|
-
|
|
74
|
-
// Get storage data
|
|
75
|
-
const appLastFetched = moment(self.storage.get(`${self.paths.app}.lastFetched`, 0).value());
|
|
76
|
-
const diff = moment().diff(appLastFetched, 'hours');
|
|
77
67
|
|
|
78
68
|
// Authenticate user (user will be resolved as well)
|
|
79
69
|
self.user = await assistant.authenticate();
|
|
@@ -100,31 +90,6 @@ Usage.prototype.init = function (assistant, options) {
|
|
|
100
90
|
}
|
|
101
91
|
|
|
102
92
|
// Log
|
|
103
|
-
self.log(`Usage.init(): Checking if usage data needs to be fetched (${diff} hours)...`);
|
|
104
|
-
|
|
105
|
-
// Get app data to get plan limits using cached data if possible
|
|
106
|
-
if (diff > 1 || options.refetch) {
|
|
107
|
-
await self.getApp(options.app)
|
|
108
|
-
.then((json) => {
|
|
109
|
-
// Write data and last fetched to storage
|
|
110
|
-
self.storage.set(`${self.paths.app}.data`, json).write();
|
|
111
|
-
self.storage.set(`${self.paths.app}.lastFetched`, new Date().toISOString()).write();
|
|
112
|
-
})
|
|
113
|
-
.catch(e => {
|
|
114
|
-
assistant.errorify(`Usage.init(): Error fetching app data: ${e}`, {code: 500, sentry: true});
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Get app data
|
|
119
|
-
self.app = self.storage.get(`${self.paths.app}.data`, {}).value();
|
|
120
|
-
|
|
121
|
-
// Check for app data
|
|
122
|
-
if (!self.app) {
|
|
123
|
-
return reject(new Error('Usage.init(): No app data found'));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Log
|
|
127
|
-
self.log(`Usage.init(): Got app data`, self.app);
|
|
128
93
|
self.log(`Usage.init(): Got user`, self.user);
|
|
129
94
|
|
|
130
95
|
// Set initialized to true
|
|
@@ -146,7 +111,7 @@ Usage.prototype.validate = function (name, options) {
|
|
|
146
111
|
options = options || {};
|
|
147
112
|
options.useCaptchaResponse = typeof options.useCaptchaResponse === 'undefined' ? true : options.useCaptchaResponse;
|
|
148
113
|
options.log = typeof options.log === 'undefined' ? true : options.log;
|
|
149
|
-
options.
|
|
114
|
+
options._forceReject = typeof options._forceReject === 'undefined' ? false : options._forceReject;
|
|
150
115
|
|
|
151
116
|
// Check for required options
|
|
152
117
|
const period = self.getUsage(name);
|
|
@@ -164,8 +129,8 @@ Usage.prototype.validate = function (name, options) {
|
|
|
164
129
|
);
|
|
165
130
|
}
|
|
166
131
|
|
|
167
|
-
//
|
|
168
|
-
if (options.
|
|
132
|
+
// Force reject (for testing/debugging)
|
|
133
|
+
if (options._forceReject) {
|
|
169
134
|
return _reject();
|
|
170
135
|
}
|
|
171
136
|
|
|
@@ -283,20 +248,31 @@ Usage.prototype.getUsage = function (name) {
|
|
|
283
248
|
}
|
|
284
249
|
};
|
|
285
250
|
|
|
286
|
-
Usage.prototype.
|
|
251
|
+
Usage.prototype.getProduct = function (id) {
|
|
287
252
|
const self = this;
|
|
288
253
|
const Manager = self.Manager;
|
|
289
|
-
const assistant = self.assistant;
|
|
290
254
|
|
|
291
|
-
|
|
292
|
-
|
|
255
|
+
const products = Manager.config.products || [];
|
|
256
|
+
|
|
257
|
+
// Look up by provided ID, or fall back to user's subscription product
|
|
258
|
+
id = id || self.user.subscription.product.id;
|
|
259
|
+
|
|
260
|
+
return products.find(p => p.id === id)
|
|
261
|
+
|| products.find(p => p.id === 'basic')
|
|
262
|
+
|| {};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
Usage.prototype.getLimit = function (name) {
|
|
266
|
+
const self = this;
|
|
293
267
|
|
|
294
|
-
|
|
268
|
+
const limits = self.getProduct().limits || {};
|
|
269
|
+
|
|
270
|
+
// Return specific limit or all limits
|
|
295
271
|
if (name) {
|
|
296
|
-
return
|
|
297
|
-
} else {
|
|
298
|
-
return _.get(self.app, key, {});
|
|
272
|
+
return limits[name] || 0;
|
|
299
273
|
}
|
|
274
|
+
|
|
275
|
+
return limits;
|
|
300
276
|
};
|
|
301
277
|
|
|
302
278
|
Usage.prototype.update = function () {
|
|
@@ -370,49 +346,4 @@ Usage.prototype.log = function () {
|
|
|
370
346
|
}
|
|
371
347
|
};
|
|
372
348
|
|
|
373
|
-
Usage.prototype.getApp = function (id) {
|
|
374
|
-
const self = this;
|
|
375
|
-
|
|
376
|
-
// Shortcuts
|
|
377
|
-
const Manager = self.Manager;
|
|
378
|
-
const assistant = self.assistant;
|
|
379
|
-
|
|
380
|
-
return new Promise(function(resolve, reject) {
|
|
381
|
-
const { admin } = Manager.libraries;
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
// If we're on ITW, we can read directly from Firestore
|
|
385
|
-
// If we don't do this, calling getApp on ITW will call getApp on ITW again and again
|
|
386
|
-
if (Manager.config.app.id === 'itw-creative-works') {
|
|
387
|
-
admin.firestore().doc(`apps/${id}`)
|
|
388
|
-
.get()
|
|
389
|
-
.then((r) => {
|
|
390
|
-
const data = r.data();
|
|
391
|
-
|
|
392
|
-
// Check for data
|
|
393
|
-
if (!data) {
|
|
394
|
-
return reject(new Error('No data found'));
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Resolve
|
|
398
|
-
return resolve(data);
|
|
399
|
-
})
|
|
400
|
-
.catch((e) => reject(e));
|
|
401
|
-
} else {
|
|
402
|
-
fetch('https://us-central1-itw-creative-works.cloudfunctions.net/getApp', {
|
|
403
|
-
method: 'post',
|
|
404
|
-
response: 'json',
|
|
405
|
-
body: {
|
|
406
|
-
id: id,
|
|
407
|
-
},
|
|
408
|
-
})
|
|
409
|
-
.then((json) => resolve(json))
|
|
410
|
-
.catch((e) => reject(e));
|
|
411
|
-
}
|
|
412
|
-
} catch (e) {
|
|
413
|
-
return reject(e);
|
|
414
|
-
}
|
|
415
|
-
});
|
|
416
|
-
};
|
|
417
|
-
|
|
418
349
|
module.exports = Usage;
|
|
@@ -40,44 +40,52 @@ function User(Manager, settings, options) {
|
|
|
40
40
|
email: settings?.auth?.email ?? null,
|
|
41
41
|
temporary: getWithDefault(settings?.auth?.temporary, false, defaults),
|
|
42
42
|
},
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
subscription: {
|
|
44
|
+
product: {
|
|
45
|
+
id: getWithDefault(settings?.subscription?.product?.id, 'basic', defaults), // product ID from config (e.g., 'basic', 'premium', 'pro')
|
|
46
|
+
name: getWithDefault(settings?.subscription?.product?.name, 'Basic', defaults), // display name from config
|
|
47
|
+
},
|
|
48
|
+
status: getWithDefault(settings?.subscription?.status, 'active', defaults), // 'active' | 'suspended' | 'cancelled'
|
|
46
49
|
expires: {
|
|
47
|
-
timestamp: getWithDefault(settings?.
|
|
48
|
-
timestampUNIX: getWithDefault(settings?.
|
|
50
|
+
timestamp: getWithDefault(settings?.subscription?.expires?.timestamp, oldDate, defaults),
|
|
51
|
+
timestampUNIX: getWithDefault(settings?.subscription?.expires?.timestampUNIX, oldDateUNIX, defaults),
|
|
49
52
|
},
|
|
50
53
|
trial: {
|
|
51
|
-
activated: getWithDefault(settings?.
|
|
54
|
+
activated: getWithDefault(settings?.subscription?.trial?.activated, false, defaults),
|
|
52
55
|
expires: {
|
|
53
|
-
timestamp: getWithDefault(settings?.
|
|
54
|
-
timestampUNIX: getWithDefault(settings?.
|
|
56
|
+
timestamp: getWithDefault(settings?.subscription?.trial?.expires?.timestamp, oldDate, defaults),
|
|
57
|
+
timestampUNIX: getWithDefault(settings?.subscription?.trial?.expires?.timestampUNIX, oldDateUNIX, defaults),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
cancellation: {
|
|
61
|
+
pending: getWithDefault(settings?.subscription?.cancellation?.pending, false, defaults),
|
|
62
|
+
date: {
|
|
63
|
+
timestamp: getWithDefault(settings?.subscription?.cancellation?.date?.timestamp, oldDate, defaults),
|
|
64
|
+
timestampUNIX: getWithDefault(settings?.subscription?.cancellation?.date?.timestampUNIX, oldDateUNIX, defaults),
|
|
55
65
|
},
|
|
56
66
|
},
|
|
57
67
|
limits: {
|
|
58
|
-
// devices: settings?.
|
|
68
|
+
// devices: settings?.subscription?.limits?.devices ?? null,
|
|
59
69
|
},
|
|
60
70
|
payment: {
|
|
61
|
-
processor: settings?.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
frequency: settings?.plan?.payment?.frequency ?? null, // monthly || annually
|
|
65
|
-
active: getWithDefault(settings?.plan?.payment?.active, false, defaults), // true | false
|
|
71
|
+
processor: settings?.subscription?.payment?.processor ?? null, // 'stripe' | 'paypal' | etc
|
|
72
|
+
resourceId: settings?.subscription?.payment?.resourceId ?? null, // subscription ID from provider (e.g., 'sub_xxx')
|
|
73
|
+
frequency: settings?.subscription?.payment?.frequency ?? null, // 'monthly' | 'annually'
|
|
66
74
|
startDate: {
|
|
67
|
-
timestamp: getWithDefault(settings?.
|
|
68
|
-
timestampUNIX: getWithDefault(settings?.
|
|
75
|
+
timestamp: getWithDefault(settings?.subscription?.payment?.startDate?.timestamp, oldDate, defaults),
|
|
76
|
+
timestampUNIX: getWithDefault(settings?.subscription?.payment?.startDate?.timestampUNIX, oldDateUNIX, defaults),
|
|
69
77
|
},
|
|
70
78
|
updatedBy: {
|
|
71
79
|
event: {
|
|
72
|
-
name: settings?.
|
|
73
|
-
id: settings?.
|
|
80
|
+
name: settings?.subscription?.payment?.updatedBy?.event?.name ?? null,
|
|
81
|
+
id: settings?.subscription?.payment?.updatedBy?.event?.id ?? null,
|
|
74
82
|
},
|
|
75
83
|
date: {
|
|
76
|
-
timestamp: getWithDefault(settings?.
|
|
77
|
-
timestampUNIX: getWithDefault(settings?.
|
|
84
|
+
timestamp: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestamp, oldDate, defaults),
|
|
85
|
+
timestampUNIX: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestampUNIX, oldDateUNIX, defaults),
|
|
78
86
|
},
|
|
79
87
|
},
|
|
80
|
-
}
|
|
88
|
+
},
|
|
81
89
|
},
|
|
82
90
|
roles: {
|
|
83
91
|
admin: getWithDefault(settings?.roles?.admin, false, defaults),
|
|
@@ -172,23 +180,6 @@ function User(Manager, settings, options) {
|
|
|
172
180
|
self.properties = pruneObject(self.properties);
|
|
173
181
|
}
|
|
174
182
|
|
|
175
|
-
self.resolve = function (options) {
|
|
176
|
-
options = options || {};
|
|
177
|
-
options.defaultPlan = options.defaultPlan || 'basic';
|
|
178
|
-
const planId = self.properties?.plan?.id ?? options.defaultPlan;
|
|
179
|
-
const premiumExpire = self.properties?.plan?.expires?.timestamp ?? 0;
|
|
180
|
-
|
|
181
|
-
let difference = ((new Date(premiumExpire).getTime() - new Date().getTime())/(24*3600*1000));
|
|
182
|
-
// console.log('---difference', difference);
|
|
183
|
-
if (difference <= -1) {
|
|
184
|
-
_.set(self.properties, 'plan.id', options.defaultPlan);
|
|
185
|
-
// console.log('---REVERTED TO BASIC BECAUSE EXPIRED');
|
|
186
|
-
} else {
|
|
187
|
-
// console.log('---ITS FINE');
|
|
188
|
-
}
|
|
189
|
-
return self;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
183
|
self.merge = function (userObject) {
|
|
193
184
|
self.properties = _.merge({}, self.properties, userObject)
|
|
194
185
|
return self;
|
package/src/manager/index.js
CHANGED
|
@@ -391,7 +391,7 @@ Manager.prototype._preProcess = function (mod) {
|
|
|
391
391
|
});
|
|
392
392
|
};
|
|
393
393
|
|
|
394
|
-
Manager.prototype._processMiddleware = function (req, res, routePath) {
|
|
394
|
+
Manager.prototype._processMiddleware = function (req, res, routePath, options) {
|
|
395
395
|
const self = this;
|
|
396
396
|
|
|
397
397
|
// Set paths for BEM internal routes/schemas
|
|
@@ -399,11 +399,11 @@ Manager.prototype._processMiddleware = function (req, res, routePath) {
|
|
|
399
399
|
const bemSchemasDir = path.resolve(__dirname, './schemas');
|
|
400
400
|
|
|
401
401
|
// Route directly through middleware (no hooks for new system)
|
|
402
|
-
return self.Middleware(req, res).run(routePath, {
|
|
402
|
+
return self.Middleware(req, res).run(routePath, Object.assign({
|
|
403
403
|
routesDir: bemRoutesDir,
|
|
404
404
|
schemasDir: bemSchemasDir,
|
|
405
405
|
schema: routePath,
|
|
406
|
-
});
|
|
406
|
+
}, options || {}));
|
|
407
407
|
};
|
|
408
408
|
|
|
409
409
|
// Manager.prototype.Assistant = function(ref, options) {
|
|
@@ -461,12 +461,6 @@ Manager.prototype.Roles = function () {
|
|
|
461
461
|
return new self.libraries.Roles(self, ...arguments);
|
|
462
462
|
};
|
|
463
463
|
|
|
464
|
-
Manager.prototype.SubscriptionResolver = function () {
|
|
465
|
-
const self = this;
|
|
466
|
-
self.libraries.SubscriptionResolver = self.libraries.SubscriptionResolver || require('./helpers/subscription-resolver.js');
|
|
467
|
-
return new self.libraries.SubscriptionResolver(self, ...arguments);
|
|
468
|
-
};
|
|
469
|
-
|
|
470
464
|
Manager.prototype.Usage = function () {
|
|
471
465
|
const self = this;
|
|
472
466
|
self.libraries.Usage = self.libraries.Usage || require('./helpers/usage.js');
|
|
@@ -719,6 +713,17 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
719
713
|
self.assistant.log('Setting up Firebase functions...');
|
|
720
714
|
}
|
|
721
715
|
|
|
716
|
+
// Route-specific middleware overrides
|
|
717
|
+
// Routes listed here get custom middleware options (e.g., skip auth for webhooks)
|
|
718
|
+
const routeMiddlewareOverrides = {
|
|
719
|
+
'payments/webhook': {
|
|
720
|
+
authenticate: false,
|
|
721
|
+
setupUsage: false,
|
|
722
|
+
setupAnalytics: false,
|
|
723
|
+
includeNonSchemaSettings: true,
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
|
|
722
727
|
// Setup functions
|
|
723
728
|
exporter.bm_api =
|
|
724
729
|
self.libraries.functions
|
|
@@ -731,7 +736,8 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
731
736
|
return self._process((new (require(`${core}/actions/api.js`))()).init(self, { req, res }));
|
|
732
737
|
} else {
|
|
733
738
|
// New RESTful middleware system -> direct to middleware (no hooks)
|
|
734
|
-
|
|
739
|
+
const overrides = routeMiddlewareOverrides[route.routePath];
|
|
740
|
+
return self._processMiddleware(req, res, route.routePath, overrides);
|
|
735
741
|
}
|
|
736
742
|
});
|
|
737
743
|
|
|
@@ -924,6 +930,12 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
924
930
|
.firestore.document('notifications/{token}')
|
|
925
931
|
.onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/notifications/on-write.js`));
|
|
926
932
|
|
|
933
|
+
exporter.bm_paymentsWebhookOnWrite =
|
|
934
|
+
self.libraries.functions
|
|
935
|
+
.runWith({memory: '256MB', timeoutSeconds: 60})
|
|
936
|
+
.firestore.document('payments-webhooks/{eventId}')
|
|
937
|
+
.onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/payments-webhooks/on-write.js`));
|
|
938
|
+
|
|
927
939
|
// Setup cron jobs
|
|
928
940
|
exporter.bm_cronDaily =
|
|
929
941
|
self.libraries.functions
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
const powertools = require('node-powertools');
|
|
2
|
+
|
|
3
|
+
// Lazy singleton Stripe SDK instance
|
|
4
|
+
let stripeInstance = null;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stripe shared library
|
|
8
|
+
* Provides SDK initialization and unified subscription transformation
|
|
9
|
+
*/
|
|
10
|
+
const Stripe = {
|
|
11
|
+
/**
|
|
12
|
+
* Initialize or return the Stripe SDK instance
|
|
13
|
+
* @param {string} secretKey - Stripe secret key
|
|
14
|
+
* @returns {object} Stripe SDK instance
|
|
15
|
+
*/
|
|
16
|
+
init() {
|
|
17
|
+
if (!stripeInstance) {
|
|
18
|
+
const secretKey = process.env.STRIPE_SECRET_KEY;
|
|
19
|
+
|
|
20
|
+
if (!secretKey) {
|
|
21
|
+
throw new Error('STRIPE_SECRET_KEY environment variable is required');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
stripeInstance = require('stripe')(secretKey);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return stripeInstance;
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Transform a raw Stripe subscription object into the unified subscription shape
|
|
32
|
+
* This produces the exact same object stored in users/{uid}.subscription
|
|
33
|
+
*
|
|
34
|
+
* @param {object} rawSubscription - Raw Stripe subscription object
|
|
35
|
+
* @param {object} options
|
|
36
|
+
* @param {object} options.config - BEM config (must contain products array)
|
|
37
|
+
* @param {string} options.eventName - Name of the webhook event (e.g., 'customer.subscription.updated')
|
|
38
|
+
* @param {string} options.eventId - ID of the webhook event (e.g., 'evt_xxx')
|
|
39
|
+
* @returns {object} Unified subscription object
|
|
40
|
+
*/
|
|
41
|
+
toUnified(rawSubscription, options) {
|
|
42
|
+
options = options || {};
|
|
43
|
+
const config = options.config || {};
|
|
44
|
+
|
|
45
|
+
const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
|
|
46
|
+
const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
|
|
47
|
+
|
|
48
|
+
// Resolve status
|
|
49
|
+
const status = resolveStatus(rawSubscription);
|
|
50
|
+
|
|
51
|
+
// Resolve cancellation
|
|
52
|
+
const cancellation = resolveCancellation(rawSubscription);
|
|
53
|
+
|
|
54
|
+
// Resolve trial
|
|
55
|
+
const trial = resolveTrial(rawSubscription);
|
|
56
|
+
|
|
57
|
+
// Resolve frequency
|
|
58
|
+
const frequency = resolveFrequency(rawSubscription);
|
|
59
|
+
|
|
60
|
+
// Resolve product from price
|
|
61
|
+
const product = resolveProduct(rawSubscription, config);
|
|
62
|
+
|
|
63
|
+
// Resolve expiration
|
|
64
|
+
const expires = resolveExpires(rawSubscription, oldDate, oldDateUNIX);
|
|
65
|
+
|
|
66
|
+
// Resolve start date
|
|
67
|
+
const startDate = resolveStartDate(rawSubscription, oldDate, oldDateUNIX);
|
|
68
|
+
|
|
69
|
+
// Build the unified subscription object
|
|
70
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
71
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
product: product,
|
|
75
|
+
status: status,
|
|
76
|
+
expires: expires,
|
|
77
|
+
trial: trial,
|
|
78
|
+
cancellation: cancellation,
|
|
79
|
+
limits: {},
|
|
80
|
+
payment: {
|
|
81
|
+
processor: 'stripe',
|
|
82
|
+
resourceId: rawSubscription.id || null,
|
|
83
|
+
frequency: frequency,
|
|
84
|
+
startDate: startDate,
|
|
85
|
+
updatedBy: {
|
|
86
|
+
event: {
|
|
87
|
+
name: options.eventName || null,
|
|
88
|
+
id: options.eventId || null,
|
|
89
|
+
},
|
|
90
|
+
date: {
|
|
91
|
+
timestamp: now,
|
|
92
|
+
timestampUNIX: nowUNIX,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map Stripe subscription status to unified status
|
|
102
|
+
*
|
|
103
|
+
* | Stripe Status | Unified Status |
|
|
104
|
+
* |----------------------|----------------|
|
|
105
|
+
* | active | active |
|
|
106
|
+
* | trialing | active |
|
|
107
|
+
* | past_due | suspended |
|
|
108
|
+
* | unpaid | suspended |
|
|
109
|
+
* | canceled | cancelled |
|
|
110
|
+
* | incomplete | cancelled |
|
|
111
|
+
* | incomplete_expired | cancelled |
|
|
112
|
+
*/
|
|
113
|
+
function resolveStatus(raw) {
|
|
114
|
+
const stripeStatus = raw.status;
|
|
115
|
+
|
|
116
|
+
if (stripeStatus === 'active' || stripeStatus === 'trialing') {
|
|
117
|
+
return 'active';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (stripeStatus === 'past_due' || stripeStatus === 'unpaid') {
|
|
121
|
+
return 'suspended';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// canceled, incomplete, incomplete_expired, or anything else
|
|
125
|
+
return 'cancelled';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve cancellation state from Stripe subscription
|
|
130
|
+
* Handles cancel_at_period_end for pending cancellations
|
|
131
|
+
*/
|
|
132
|
+
function resolveCancellation(raw) {
|
|
133
|
+
const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
|
|
134
|
+
const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
|
|
135
|
+
|
|
136
|
+
// Pending cancellation: active but set to cancel at period end
|
|
137
|
+
if (raw.cancel_at_period_end) {
|
|
138
|
+
const cancelAt = raw.cancel_at
|
|
139
|
+
? powertools.timestamp(new Date(raw.cancel_at * 1000), { output: 'string' })
|
|
140
|
+
: powertools.timestamp(new Date((raw.current_period_end || 0) * 1000), { output: 'string' });
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
pending: true,
|
|
144
|
+
date: {
|
|
145
|
+
timestamp: cancelAt,
|
|
146
|
+
timestampUNIX: powertools.timestamp(cancelAt, { output: 'unix' }),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Already cancelled
|
|
152
|
+
if (raw.canceled_at) {
|
|
153
|
+
const cancelledDate = powertools.timestamp(new Date(raw.canceled_at * 1000), { output: 'string' });
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
pending: false,
|
|
157
|
+
date: {
|
|
158
|
+
timestamp: cancelledDate,
|
|
159
|
+
timestampUNIX: powertools.timestamp(cancelledDate, { output: 'unix' }),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// No cancellation
|
|
165
|
+
return {
|
|
166
|
+
pending: false,
|
|
167
|
+
date: {
|
|
168
|
+
timestamp: oldDate,
|
|
169
|
+
timestampUNIX: oldDateUNIX,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve trial state from Stripe subscription
|
|
176
|
+
*/
|
|
177
|
+
function resolveTrial(raw) {
|
|
178
|
+
const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
|
|
179
|
+
const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
|
|
180
|
+
|
|
181
|
+
const trialStart = raw.trial_start ? raw.trial_start * 1000 : 0;
|
|
182
|
+
const trialEnd = raw.trial_end ? raw.trial_end * 1000 : 0;
|
|
183
|
+
const activated = !!(trialStart && trialEnd);
|
|
184
|
+
|
|
185
|
+
// Build trial expiration
|
|
186
|
+
let trialExpires = { timestamp: oldDate, timestampUNIX: oldDateUNIX };
|
|
187
|
+
if (trialEnd) {
|
|
188
|
+
const trialEndDate = powertools.timestamp(new Date(trialEnd), { output: 'string' });
|
|
189
|
+
trialExpires = {
|
|
190
|
+
timestamp: trialEndDate,
|
|
191
|
+
timestampUNIX: powertools.timestamp(trialEndDate, { output: 'unix' }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
activated: activated,
|
|
197
|
+
expires: trialExpires,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve billing frequency from Stripe subscription
|
|
203
|
+
*/
|
|
204
|
+
function resolveFrequency(raw) {
|
|
205
|
+
// Stripe stores interval on the plan/price object
|
|
206
|
+
const interval = raw.plan?.interval
|
|
207
|
+
|| raw.items?.data?.[0]?.price?.recurring?.interval
|
|
208
|
+
|| null;
|
|
209
|
+
|
|
210
|
+
if (interval === 'year') {
|
|
211
|
+
return 'annually';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (interval === 'month') {
|
|
215
|
+
return 'monthly';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (interval === 'week') {
|
|
219
|
+
return 'weekly';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (interval === 'day') {
|
|
223
|
+
return 'daily';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve product by matching the Stripe price ID against config products
|
|
231
|
+
* Returns { id, name } — falls back to basic if no match is found
|
|
232
|
+
*/
|
|
233
|
+
function resolveProduct(raw, config) {
|
|
234
|
+
// Get the price ID from the subscription
|
|
235
|
+
const priceId = raw.plan?.id
|
|
236
|
+
|| raw.items?.data?.[0]?.price?.id
|
|
237
|
+
|| null;
|
|
238
|
+
|
|
239
|
+
if (!priceId || !config.products) {
|
|
240
|
+
return { id: 'basic', name: 'Basic' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Search through products for a matching price ID
|
|
244
|
+
for (const product of config.products) {
|
|
245
|
+
if (!product.prices) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const frequency of Object.keys(product.prices)) {
|
|
250
|
+
if (product.prices[frequency]?.stripe === priceId) {
|
|
251
|
+
return { id: product.id, name: product.name || product.id };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// No match found
|
|
257
|
+
return { id: 'basic', name: 'Basic' };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve subscription expiration from Stripe data
|
|
262
|
+
*/
|
|
263
|
+
function resolveExpires(raw, oldDate, oldDateUNIX) {
|
|
264
|
+
// For active/trialing subscriptions, use current_period_end
|
|
265
|
+
const periodEnd = raw.current_period_end
|
|
266
|
+
? powertools.timestamp(new Date(raw.current_period_end * 1000), { output: 'string' })
|
|
267
|
+
: oldDate;
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
timestamp: periodEnd,
|
|
271
|
+
timestampUNIX: periodEnd !== oldDate
|
|
272
|
+
? powertools.timestamp(periodEnd, { output: 'unix' })
|
|
273
|
+
: oldDateUNIX,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Resolve subscription start date from Stripe data
|
|
279
|
+
*/
|
|
280
|
+
function resolveStartDate(raw, oldDate, oldDateUNIX) {
|
|
281
|
+
const startDate = raw.start_date
|
|
282
|
+
? powertools.timestamp(new Date(raw.start_date * 1000), { output: 'string' })
|
|
283
|
+
: oldDate;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
timestamp: startDate,
|
|
287
|
+
timestampUNIX: startDate !== oldDate
|
|
288
|
+
? powertools.timestamp(startDate, { output: 'unix' })
|
|
289
|
+
: oldDateUNIX,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = Stripe;
|
|
@@ -157,7 +157,7 @@ async function getAllSubscriptions(admin, assistant) {
|
|
|
157
157
|
assistant.log('getAllSubscriptions(): Starting...');
|
|
158
158
|
|
|
159
159
|
const snapshot = await admin.firestore().collection('users')
|
|
160
|
-
.where('
|
|
160
|
+
.where('subscription.expires.timestampUNIX', '>=', Date.now() / 1000)
|
|
161
161
|
.get();
|
|
162
162
|
|
|
163
163
|
const stats = {
|
|
@@ -167,8 +167,8 @@ async function getAllSubscriptions(admin, assistant) {
|
|
|
167
167
|
|
|
168
168
|
snapshot.forEach((doc) => {
|
|
169
169
|
const data = doc.data();
|
|
170
|
-
const planId = data?.
|
|
171
|
-
const frequency = data?.
|
|
170
|
+
const planId = data?.subscription?.product?.id || 'basic';
|
|
171
|
+
const frequency = data?.subscription?.payment?.frequency || 'unknown';
|
|
172
172
|
const isAdmin = data?.roles?.admin || false;
|
|
173
173
|
const isVip = data?.roles?.vip || false;
|
|
174
174
|
|
|
@@ -58,7 +58,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
58
58
|
|
|
59
59
|
// Check rate limit via Usage API
|
|
60
60
|
try {
|
|
61
|
-
await usage.validate('marketing-subscribe', {
|
|
61
|
+
await usage.validate('marketing-subscribe', { useCaptchaResponse: false });
|
|
62
62
|
usage.increment('marketing-subscribe');
|
|
63
63
|
await usage.update();
|
|
64
64
|
} catch (e) {
|