backend-manager 5.0.84 → 5.0.85

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CLAUDE.md +66 -3
  3. package/README.md +7 -5
  4. package/package.json +5 -4
  5. package/src/cli/commands/base-command.js +89 -0
  6. package/src/cli/commands/emulators.js +3 -0
  7. package/src/cli/commands/serve.js +5 -1
  8. package/src/cli/commands/stripe.js +14 -0
  9. package/src/cli/commands/test.js +11 -6
  10. package/src/cli/index.js +7 -0
  11. package/src/manager/cron/daily/reset-usage.js +56 -34
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +15 -13
  13. package/src/manager/functions/core/actions/api/user/get-subscription-info.js +1 -1
  14. package/src/manager/helpers/analytics.js +2 -2
  15. package/src/manager/helpers/api-manager.js +1 -1
  16. package/src/manager/helpers/usage.js +51 -3
  17. package/src/manager/index.js +5 -19
  18. package/src/manager/libraries/stripe.js +12 -8
  19. package/src/manager/libraries/test.js +27 -0
  20. package/src/manager/routes/app/get.js +11 -8
  21. package/src/manager/routes/payments/intent/post.js +31 -16
  22. package/src/manager/routes/payments/intent/{providers → processors}/stripe.js +48 -4
  23. package/src/manager/routes/payments/intent/processors/test.js +106 -0
  24. package/src/manager/routes/payments/webhook/post.js +21 -8
  25. package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
  26. package/src/manager/routes/payments/webhook/processors/test.js +15 -0
  27. package/src/manager/routes/user/subscription/get.js +1 -1
  28. package/src/manager/schemas/payments/webhook/post.js +1 -1
  29. package/src/test/test-accounts.js +18 -18
  30. package/templates/_.env +0 -2
  31. package/templates/backend-manager-config.json +50 -34
  32. package/test/events/payments/journey-payments-cancel.js +144 -0
  33. package/test/events/payments/journey-payments-suspend.js +143 -0
  34. package/test/events/payments/journey-payments-trial.js +120 -0
  35. package/test/events/payments/journey-payments-upgrade.js +99 -0
  36. package/test/fixtures/stripe/subscription-active.json +161 -0
  37. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  38. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  39. package/test/functions/user/get-subscription-info.js +2 -2
  40. package/test/helpers/stripe-to-unified.js +684 -0
  41. package/test/routes/payments/intent.js +189 -0
  42. package/test/{payments → routes/payments}/webhook.js +1 -1
  43. package/test/routes/test/usage.js +7 -6
  44. package/test/routes/user/subscription.js +2 -2
  45. package/test/payments/intent.js +0 -104
  46. package/test/payments/journey-payment-cancel.js +0 -166
  47. package/test/payments/journey-payment-suspend.js +0 -162
  48. package/test/payments/journey-payment-trial.js +0 -167
  49. package/test/payments/journey-payment-upgrade.js +0 -136
package/CHANGELOG.md CHANGED
@@ -14,6 +14,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.0.84] - 2026-02-19
18
+ ### BREAKING
19
+ - Moved `config.products` to `config.payment.products`. All product lookups now use `config.payment.products`.
20
+ - Renamed `subscription.trial.activated` to `subscription.trial.claimed` across the entire subscription schema, API responses, analytics properties, and tests.
21
+ - Renamed analytics user property `plan_id` to `subscription_id` and `plan_trial_activated` to `subscription_trial_claimed`.
22
+ - Removed `Manager.getApp()` method (previously fetched from ITW Creative Works endpoint).
23
+ - Removed `Manager.SubscriptionResolver()` factory method.
24
+ - Removed deprecated `RUNTIME_CONFIG` .env loading from config merge.
25
+ - Test accounts now use `subscription.*` instead of `plan.*`.
26
+
27
+ ### Added
28
+ - Stripe payment integration with shared library (`src/manager/libraries/stripe.js`) and `toUnified()` transformer that maps Stripe subscription states to the unified subscription schema.
29
+ - Test payment processor library that delegates to Stripe's transformer with `processor: 'test'`.
30
+ - Payment webhook route (`POST /payments/webhook`) with processor-specific handlers for Stripe (with signature verification) and test, including idempotent event storage in `payments-webhooks` Firestore collection.
31
+ - Payment intent route (`POST /payments/intent`) for creating checkout sessions with processor-specific handlers.
32
+ - Firestore trigger (`bm_paymentsWebhookOnWrite`) that processes stored webhook events and updates user subscription documents.
33
+ - Payment schemas for webhook and intent validation.
34
+ - `payment.processors` config section for Stripe, PayPal, Chargebee, and Coinbase configuration.
35
+ - `npx bm stripe` CLI command for standalone Stripe webhook forwarding.
36
+ - Auto-start Stripe CLI webhook forwarding with `npx bm emulators` (gracefully skips when prerequisites are missing).
37
+ - `Manager.version` property exposing the BEM package version.
38
+ - Journey test accounts for payment lifecycle testing (upgrade, cancel, suspend, trial).
39
+ - Stripe fixture data for subscription states (active, trialing, canceled).
40
+ - Tests for `stripe-to-unified` transformer, payment webhook route, and payment intent route.
41
+ - Test cleanup for payment-related Firestore collections (`payments-subscriptions`, `payments-webhooks`, `payments-intents`).
42
+
43
+ ### Changed
44
+ - Cron schedule from `every 24 hours` to `0 0 * * *` (explicit midnight UTC).
45
+ - Test runner now passes full config object (with convenience aliases) for payment processor access.
46
+ - Unauthenticated usage tests now use relative assertions instead of absolute values.
47
+
48
+ ### Removed
49
+ - Removed `PAYPAL_CLIENT_ID` and `CHARGEBEE_SITE` from `.env` template (now configured via `payment.processors` in config).
50
+
17
51
  # [5.0.39] - 2025-01-12
18
52
  ### Added
19
53
  - New test infrastructure with Firebase emulator support for reliable, isolated testing.
package/CLAUDE.md CHANGED
@@ -490,6 +490,67 @@ assert.fail(message) // Explicit fail
490
490
  | `src/test/utils/http-client.js` | HTTP client |
491
491
  | `src/test/test-accounts.js` | Test account definitions |
492
492
 
493
+ ## Stripe Webhook Forwarding
494
+
495
+ BEM auto-starts Stripe CLI webhook forwarding when running `npx bm serve` or `npx bm emulators`. This forwards Stripe test webhooks to the local server so the full payment pipeline works end-to-end during development.
496
+
497
+ **Requirements:**
498
+ - `STRIPE_SECRET_KEY` set in `functions/.env`
499
+ - `BACKEND_MANAGER_KEY` set in `functions/.env`
500
+ - [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
501
+
502
+ **Standalone usage:**
503
+ ```bash
504
+ npx bm stripe
505
+ ```
506
+
507
+ If any prerequisite is missing, webhook forwarding is silently skipped with an info message.
508
+
509
+ The forwarding URL is: `http://localhost:{hostingPort}/backend-manager/payments/webhook?processor=stripe&key={BACKEND_MANAGER_KEY}`
510
+
511
+ ## Usage & Rate Limiting
512
+
513
+ ### Overview
514
+
515
+ Usage is tracked per-metric (e.g., `requests`, `marketing-subscribe`) with two counters:
516
+ - `period`: Current month's count, reset on the 1st of each month
517
+ - `total`: All-time count, never resets
518
+
519
+ ### Product Rate Limit Modes
520
+
521
+ Products can set a `rateLimit` field to control how limits are enforced:
522
+
523
+ | Value | Behavior | Default |
524
+ |-------|----------|---------|
525
+ | `'monthly'` | Full monthly limit available at any time | Yes |
526
+ | `'daily'` | Proportional daily cap: `ceil(limit * dayOfMonth / daysInMonth)` | No |
527
+
528
+ Example config (not in the template — add per-product as needed):
529
+ ```json
530
+ {
531
+ "id": "basic",
532
+ "limits": { "requests": 100 },
533
+ "rateLimit": "daily"
534
+ }
535
+ ```
536
+
537
+ With `rateLimit: 'daily'` and 100 requests/month in a 30-day month:
538
+ - Day 1: max 4 requests used so far
539
+ - Day 15: max 50 requests used so far
540
+ - Day 30: max 100 requests (full allocation)
541
+
542
+ Unused days roll forward — a user who doesn't use the product for 2 weeks can use a burst later.
543
+
544
+ ### Reset Schedule
545
+
546
+ | Target | Frequency | What happens |
547
+ |--------|-----------|-------------|
548
+ | Local storage | Daily | Cleared entirely |
549
+ | `usage` collection (unauthenticated) | Daily | Deleted entirely |
550
+ | User doc `usage.*.period` (authenticated) | Monthly (1st) | Reset to 0 |
551
+
552
+ The daily cron runs at midnight UTC (`0 0 * * *`). Authenticated user period resets only execute on the 1st of the month.
553
+
493
554
  ## Subscription System
494
555
 
495
556
  ### Subscription Statuses
@@ -505,7 +566,7 @@ assert.fail(message) // Explicit fail
505
566
  | Stripe Status | `subscription.status` | Notes |
506
567
  |---|---|---|
507
568
  | `active` | `active` | Normal active subscription |
508
- | `trialing` | `active` | `trial.activated = true` |
569
+ | `trialing` | `active` | `trial.claimed = true` |
509
570
  | `past_due` | `suspended` | Payment failed, retrying |
510
571
  | `unpaid` | `suspended` | Payment failed |
511
572
  | `canceled` | `cancelled` | Subscription terminated |
@@ -524,7 +585,7 @@ subscription: {
524
585
  status: 'active', // 'active' | 'suspended' | 'cancelled'
525
586
  expires: { timestamp, timestampUNIX },
526
587
  trial: {
527
- activated: false, // has user EVER used a trial
588
+ claimed: false, // has user EVER used a trial
528
589
  expires: { timestamp, timestampUNIX },
529
590
  },
530
591
  cancellation: {
@@ -551,7 +612,7 @@ subscription: {
551
612
  user.subscription.status === 'active' && user.subscription.product.id !== 'basic'
552
613
 
553
614
  // Is on trial?
554
- user.subscription.trial.activated && user.subscription.status === 'active'
615
+ user.subscription.trial.claimed && user.subscription.status === 'active'
555
616
 
556
617
  // Has pending cancellation?
557
618
  user.subscription.cancellation.pending === true
@@ -592,6 +653,8 @@ user.subscription.status === 'suspended'
592
653
  | Main API handler | `src/manager/functions/core/actions/api.js` |
593
654
  | Config template | `templates/backend-manager-config.json` |
594
655
  | CLI entry | `src/cli/index.js` |
656
+ | Stripe webhook forwarding | `src/cli/commands/stripe.js` |
657
+ | Stripe shared library | `src/manager/libraries/stripe.js` |
595
658
 
596
659
  ## Environment Detection
597
660
 
package/README.md CHANGED
@@ -464,7 +464,7 @@ const userProps = Manager.User(existingData, { defaults: true }).properties;
464
464
  product: { id, name }, // product from config ('basic', 'premium', etc.)
465
465
  status: 'active', // active | suspended | cancelled
466
466
  expires: { timestamp, timestampUNIX },
467
- trial: { activated, expires: {...} },
467
+ trial: { claimed, expires: {...} },
468
468
  cancellation: { pending, date: {...} },
469
469
  limits: {},
470
470
  payment: { processor, resourceId, frequency, startDate, updatedBy }
@@ -501,7 +501,7 @@ analytics.event('purchase', {
501
501
 
502
502
  **Auto-tracked User Properties:**
503
503
  - `app_version`, `device_category`, `operating_system`, `platform`
504
- - `authenticated`, `plan_id`, `plan_trial_activated`, `activity_created`
504
+ - `authenticated`, `subscription_id`, `subscription_trial_claimed`, `activity_created`
505
505
  - `country`, `city`, `language`, `age`, `gender`
506
506
 
507
507
  ### Usage
@@ -734,6 +734,7 @@ npx backend-manager <command>
734
734
  | `bem deploy` | Deploy functions to Firebase |
735
735
  | `bem test [paths...]` | Run integration tests |
736
736
  | `bem emulators` | Start Firebase emulators (keep-alive mode) |
737
+ | `bem stripe` | Start Stripe CLI webhook forwarding to local server |
737
738
  | `bem version`, `bem v` | Show BEM version |
738
739
  | `bem clear` | Clear cache and temp files |
739
740
  | `bem install`, `bem i` | Install BEM (local or production) |
@@ -748,6 +749,7 @@ Set these in your `functions/.env` file:
748
749
  | Variable | Description |
749
750
  |----------|-------------|
750
751
  | `BACKEND_MANAGER_KEY` | Admin authentication key |
752
+ | `STRIPE_SECRET_KEY` | Stripe secret key (enables auto webhook forwarding in `serve`/`emulators`) |
751
753
 
752
754
  ## Response Headers
753
755
 
@@ -874,7 +876,7 @@ BEM includes a built-in payment/subscription system with Stripe integration (ext
874
876
  | Stripe Status | `subscription.status` | Notes |
875
877
  |---|---|---|
876
878
  | `active` | `active` | Normal active subscription |
877
- | `trialing` | `active` | `trial.activated = true` |
879
+ | `trialing` | `active` | `trial.claimed = true` |
878
880
  | `past_due` | `suspended` | Payment failed, retrying |
879
881
  | `unpaid` | `suspended` | Payment failed |
880
882
  | `canceled` | `cancelled` | Subscription terminated |
@@ -895,7 +897,7 @@ subscription: {
895
897
  status: 'active', // 'active' | 'suspended' | 'cancelled'
896
898
  expires: { timestamp, timestampUNIX },
897
899
  trial: {
898
- activated: false, // has user EVER used a trial
900
+ claimed: false, // has user EVER used a trial
899
901
  expires: { timestamp, timestampUNIX },
900
902
  },
901
903
  cancellation: {
@@ -922,7 +924,7 @@ subscription: {
922
924
  user.subscription.status === 'active' && user.subscription.product.id !== 'basic'
923
925
 
924
926
  // Is on trial?
925
- user.subscription.trial.activated && user.subscription.status === 'active'
927
+ user.subscription.trial.claimed && user.subscription.status === 'active'
926
928
 
927
929
  // Has pending cancellation?
928
930
  user.subscription.cancellation.pending === true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.84",
3
+ "version": "5.0.85",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -44,8 +44,8 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@firebase/rules-unit-testing": "^5.0.0",
47
- "@google-cloud/pubsub": "^5.2.2",
48
- "@google-cloud/storage": "^7.18.0",
47
+ "@google-cloud/pubsub": "^5.3.0",
48
+ "@google-cloud/storage": "^7.19.0",
49
49
  "@octokit/rest": "^19.0.13",
50
50
  "@sendgrid/mail": "^7.7.0",
51
51
  "@sentry/node": "^6.19.7",
@@ -75,6 +75,7 @@
75
75
  "paypal-server-api": "^2.0.14",
76
76
  "pushid": "^1.0.0",
77
77
  "shortid": "^2.2.17",
78
+ "stripe": "^20.3.1",
78
79
  "uid-generator": "^2.0.0",
79
80
  "uuid": "^9.0.1",
80
81
  "wonderful-fetch": "^1.3.4",
@@ -89,7 +90,7 @@
89
90
  "sinon": "^21.0.1"
90
91
  },
91
92
  "peerDependencies": {
92
- "firebase-admin": "^13.6.0",
93
+ "firebase-admin": "^13.6.1",
93
94
  "firebase-functions": "^7.0.5"
94
95
  }
95
96
  }
@@ -156,6 +156,95 @@ class BaseCommand {
156
156
  isPortInUse(port) {
157
157
  return this.getProcessOnPort(port) !== null;
158
158
  }
159
+
160
+ /**
161
+ * Start Stripe CLI webhook forwarding in the background
162
+ * Forwards Stripe test webhooks to the local server
163
+ * Gracefully skips if stripe CLI or STRIPE_SECRET_KEY is missing
164
+ * @returns {object|null} - Child process handle or null if skipped
165
+ */
166
+ startStripeWebhookForwarding() {
167
+ const projectDir = this.main.firebaseProjectPath;
168
+ const functionsDir = path.join(projectDir, 'functions');
169
+
170
+ // Load .env so STRIPE_SECRET_KEY and BACKEND_MANAGER_KEY are available
171
+ const envPath = path.join(functionsDir, '.env');
172
+ if (jetpack.exists(envPath)) {
173
+ require('dotenv').config({ path: envPath });
174
+ }
175
+
176
+ // Check for Stripe secret key
177
+ if (!process.env.STRIPE_SECRET_KEY) {
178
+ this.log(chalk.gray(' (Stripe webhook forwarding disabled - STRIPE_SECRET_KEY not set in .env)\n'));
179
+ return null;
180
+ }
181
+
182
+ // Check for Backend Manager key
183
+ if (!process.env.BACKEND_MANAGER_KEY) {
184
+ this.log(chalk.gray(' (Stripe webhook forwarding disabled - BACKEND_MANAGER_KEY not set in .env)\n'));
185
+ return null;
186
+ }
187
+
188
+ // Check if stripe CLI is installed
189
+ let stripePath;
190
+ try {
191
+ stripePath = execSync('which stripe', { encoding: 'utf8' }).trim();
192
+ } catch (e) {
193
+ this.log(chalk.gray(' (Stripe webhook forwarding disabled - install Stripe CLI: https://stripe.com/docs/stripe-cli)\n'));
194
+ return null;
195
+ }
196
+
197
+ // Resolve hosting port from firebase.json (default 5002)
198
+ let hostingPort = 5002;
199
+ const firebaseJsonPath = path.join(projectDir, 'firebase.json');
200
+ if (jetpack.exists(firebaseJsonPath)) {
201
+ try {
202
+ const JSON5 = require('json5');
203
+ const firebaseConfig = JSON5.parse(jetpack.read(firebaseJsonPath));
204
+ hostingPort = firebaseConfig.emulators?.hosting?.port || hostingPort;
205
+ } catch (e) {
206
+ // Use default
207
+ }
208
+ }
209
+
210
+ const forwardUrl = `http://localhost:${hostingPort}/backend-manager/payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`;
211
+
212
+ this.log(chalk.gray(` Stripe webhook forwarding -> localhost:${hostingPort}\n`));
213
+
214
+ const stripeProcess = spawn(stripePath, [
215
+ 'listen',
216
+ '--forward-to', forwardUrl,
217
+ '--api-key', process.env.STRIPE_SECRET_KEY,
218
+ ], {
219
+ stdio: ['ignore', 'pipe', 'pipe'],
220
+ detached: false,
221
+ });
222
+
223
+ // Prefix output with [Stripe]
224
+ const prefixStream = (stream) => {
225
+ stream.on('data', (data) => {
226
+ const lines = data.toString().split('\n').filter(l => l.trim());
227
+ for (const line of lines) {
228
+ console.log(chalk.gray(` [Stripe] ${line}`));
229
+ }
230
+ });
231
+ };
232
+
233
+ prefixStream(stripeProcess.stdout);
234
+ prefixStream(stripeProcess.stderr);
235
+
236
+ stripeProcess.on('error', (error) => {
237
+ this.log(chalk.yellow(` [Stripe] Error: ${error.message}`));
238
+ });
239
+
240
+ stripeProcess.on('close', (code) => {
241
+ if (code !== 0 && code !== null) {
242
+ this.log(chalk.yellow(` [Stripe] Exited with code ${code}`));
243
+ }
244
+ });
245
+
246
+ return stripeProcess;
247
+ }
159
248
  }
160
249
 
161
250
  module.exports = BaseCommand;
@@ -23,6 +23,9 @@ class EmulatorsCommand extends BaseCommand {
23
23
  const watcher = new WatchCommand(this.main);
24
24
  watcher.startBackground();
25
25
 
26
+ // Start Stripe webhook forwarding in background
27
+ this.startStripeWebhookForwarding();
28
+
26
29
  // Run emulators with keep-alive command (use single quotes since runWithEmulators wraps in double quotes)
27
30
  const keepAliveCommand = "echo ''; echo 'Emulators ready. Press Ctrl+C to shut down...'; sleep 86400";
28
31
 
@@ -11,9 +11,13 @@ class ServeCommand extends BaseCommand {
11
11
  const watcher = new WatchCommand(self);
12
12
  watcher.startBackground();
13
13
 
14
+ // Start Stripe webhook forwarding in background
15
+ // Ignored because we cant really fully process them unless the emulator is running
16
+ // this.startStripeWebhookForwarding();
17
+
14
18
  // Execute
15
19
  await powertools.execute(`firebase serve --port ${port}`, { log: true });
16
20
  }
17
21
  }
18
22
 
19
- module.exports = ServeCommand;
23
+ module.exports = ServeCommand;
@@ -0,0 +1,14 @@
1
+ const BaseCommand = require('./base-command');
2
+
3
+ class StripeCommand extends BaseCommand {
4
+ async execute() {
5
+ const stripeProcess = this.startStripeWebhookForwarding();
6
+
7
+ if (stripeProcess) {
8
+ // Keep alive until Ctrl+C
9
+ await new Promise(() => {});
10
+ }
11
+ }
12
+ }
13
+
14
+ module.exports = StripeCommand;
@@ -102,14 +102,10 @@ class TestCommand extends BaseCommand {
102
102
  return null;
103
103
  }
104
104
 
105
- // Extract values from expected config structure
105
+ // Derive convenience values
106
106
  const projectId = config.firebaseConfig?.projectId;
107
107
  const backendManagerKey = argv.key || process.env.BACKEND_MANAGER_KEY;
108
108
  const appId = config.brand?.id;
109
- const brandName = config.brand?.name;
110
- const githubRepoWebsite = config.github?.repo_website;
111
-
112
- // Extract domain from brand.contact.email (e.g., 'support@example.com' -> 'example.com')
113
109
  const contactEmail = config.brand?.contact?.email || '';
114
110
  const domain = contactEmail.includes('@') ? contactEmail.split('@')[1] : '';
115
111
 
@@ -135,7 +131,16 @@ class TestCommand extends BaseCommand {
135
131
  return null;
136
132
  }
137
133
 
138
- return { appId, projectId, backendManagerKey, domain, brandName, githubRepoWebsite };
134
+ // Pass entire config + convenience aliases used by runner/helpers
135
+ return {
136
+ ...config,
137
+ appId,
138
+ projectId,
139
+ backendManagerKey,
140
+ domain,
141
+ brandName: config.brand?.name,
142
+ githubRepoWebsite: config.github?.repo_website,
143
+ };
139
144
  }
140
145
 
141
146
  /**
package/src/cli/index.js CHANGED
@@ -14,6 +14,7 @@ const EmulatorsCommand = require('./commands/emulators');
14
14
  const CleanCommand = require('./commands/clean');
15
15
  const IndexesCommand = require('./commands/indexes');
16
16
  const WatchCommand = require('./commands/watch');
17
+ const StripeCommand = require('./commands/stripe');
17
18
 
18
19
  function Main() {}
19
20
 
@@ -111,6 +112,12 @@ Main.prototype.process = async function (args) {
111
112
  const cmd = new WatchCommand(self);
112
113
  return await cmd.execute();
113
114
  }
115
+
116
+ // Stripe webhook forwarding (standalone)
117
+ if (self.options['stripe'] || self.options['stripe:listen']) {
118
+ const cmd = new StripeCommand(self);
119
+ return await cmd.execute();
120
+ }
114
121
  };
115
122
 
116
123
  // Test method for setup command
@@ -1,56 +1,86 @@
1
1
  /**
2
2
  * Reset usage cron job
3
3
  *
4
- * Resets daily usage counters in both local storage and Firestore.
4
+ * Runs daily at midnight UTC and handles different reset schedules:
5
+ * - Local storage: cleared every day
6
+ * - Unauthenticated usage collection: deleted every day
7
+ * - Authenticated user period counters: reset on the 1st (or 2nd as grace window) of each month
5
8
  */
6
9
  module.exports = async ({ Manager, assistant, context, libraries }) => {
7
10
  const storage = Manager.storage({ name: 'usage', temporary: true, clear: false, log: false });
8
11
 
9
12
  assistant.log('Starting...');
10
13
 
11
- // Clear local
14
+ // Clear local storage (daily)
12
15
  await clearLocal(assistant, storage);
13
16
 
14
- // Clear firestore
15
- await clearFirestore(Manager, assistant, libraries);
17
+ // Clear unauthenticated usage collection (daily)
18
+ await clearUnauthenticatedUsage(assistant, libraries);
19
+
20
+ // Reset authenticated user periods (monthly - 1st or 2nd of month)
21
+ await resetAuthenticatedUsage(Manager, assistant, libraries);
16
22
  };
17
23
 
18
24
  async function clearLocal(assistant, storage) {
19
- // Log status
20
25
  assistant.log('[local]: Starting...');
21
26
 
22
- // Log storage
23
27
  assistant.log('[local]: storage(apps)', storage.get('apps', {}).value());
24
28
  assistant.log('[local]: storage(users)', storage.get('users', {}).value());
25
29
 
26
30
  // Clear storage
27
31
  storage.setState({}).write();
28
32
 
29
- // Log status
30
33
  assistant.log('[local]: Completed!');
31
34
  }
32
35
 
33
- async function clearFirestore(Manager, assistant, libraries) {
36
+ async function clearUnauthenticatedUsage(assistant, libraries) {
37
+ const { admin } = libraries;
38
+
39
+ assistant.log('[unauthenticated]: Deleting usage collection...');
40
+
41
+ await admin.firestore().recursiveDelete(admin.firestore().collection('usage'))
42
+ .then(() => {
43
+ assistant.log('[unauthenticated]: Deleted usage collection');
44
+ })
45
+ .catch((e) => {
46
+ assistant.errorify(`Error deleting usage collection: ${e}`, { code: 500, log: true });
47
+ });
48
+ }
49
+
50
+ async function resetAuthenticatedUsage(Manager, assistant, libraries) {
34
51
  const { admin } = libraries;
52
+ const dayOfMonth = new Date().getDate();
35
53
 
36
- // Log status
37
- assistant.log('[firestore]: Starting...');
54
+ // Only reset on the 1st of the month
55
+ if (dayOfMonth !== 1) {
56
+ assistant.log('[authenticated]: Skipping period reset (not the 1st of the month)');
57
+ return;
58
+ }
59
+
60
+ assistant.log('[authenticated]: Monthly reset starting...');
61
+
62
+ // Gather all unique metric names from ALL products
63
+ const products = Manager.config.payment?.products || [];
64
+ const metrics = {};
65
+
66
+ for (const product of products) {
67
+ const limits = product.limits || {};
68
+
69
+ for (const key of Object.keys(limits)) {
70
+ metrics[key] = true;
71
+ }
72
+ }
38
73
 
39
- // Get metric names from the basic product in config
40
- const products = Manager.config.products || [];
41
- const basicProduct = products.find(p => p.id === 'basic') || {};
42
- const metrics = { ...(basicProduct.limits || {}) };
74
+ // Ensure requests is always included
75
+ metrics.requests = true;
43
76
 
44
- // Ensure requests is always included as a default metric
45
- metrics.requests = metrics.requests || 1;
77
+ const metricNames = Object.keys(metrics);
46
78
 
47
- // Log status
48
- assistant.log('[firestore]: Resetting metrics', metrics);
79
+ assistant.log('[authenticated]: Resetting metrics', metricNames);
49
80
 
50
- // Reset all metrics with for loop of metrics
51
- // TODO: OPTIMIZATION: Put all of the changes into a single batch
52
- for (const metric of Object.keys(metrics)) {
53
- assistant.log(`[firestore]: Resetting ${metric} for all users`);
81
+ // Reset each metric for users who have usage > 0
82
+ for (const metric of metricNames) {
83
+ assistant.log(`[authenticated]: Resetting ${metric} for all users`);
54
84
 
55
85
  await Manager.Utilities().iterateCollection((batch, index) => {
56
86
  return new Promise(async (resolve, reject) => {
@@ -64,7 +94,7 @@ async function clearFirestore(Manager, assistant, libraries) {
64
94
  data.usage[metric].total = data.usage[metric].total || 0;
65
95
  data.usage[metric].last = data.usage[metric].last || {};
66
96
 
67
- // Yeet if its 0
97
+ // Skip if already 0
68
98
  if (data.usage[metric].period <= 0) {
69
99
  continue;
70
100
  }
@@ -76,14 +106,13 @@ async function clearFirestore(Manager, assistant, libraries) {
76
106
  // Update the doc
77
107
  await doc.ref.update({ usage: data.usage })
78
108
  .then(r => {
79
- assistant.log(`[firestore]: Reset ${metric} for ${doc.id} (${original} -> 0)`);
109
+ assistant.log(`[authenticated]: Reset ${metric} for ${doc.id} (${original} -> 0)`);
80
110
  })
81
111
  .catch(e => {
82
112
  assistant.errorify(`Error resetting ${metric} for ${doc.id}: ${e}`, { code: 500, log: true });
83
113
  });
84
114
  }
85
115
 
86
- // Complete
87
116
  return resolve();
88
117
  });
89
118
  }, {
@@ -95,19 +124,12 @@ async function clearFirestore(Manager, assistant, libraries) {
95
124
  log: true,
96
125
  })
97
126
  .then((r) => {
98
- assistant.log(`[firestore]: Reset ${metric} for all users complete!`);
127
+ assistant.log(`[authenticated]: Reset ${metric} for all users complete!`);
99
128
  })
100
129
  .catch(e => {
101
130
  assistant.errorify(`Error resetting ${metric} for all users: ${e}`, { code: 500, log: true });
102
131
  });
103
132
  }
104
133
 
105
- // Clear usage in firestore by deleting the entire collection
106
- await admin.firestore().recursiveDelete(admin.firestore().collection('usage'))
107
- .then(() => {
108
- assistant.log('[firestore]: Deleted usage collection');
109
- })
110
- .catch((e) => {
111
- assistant.errorify(`Error deleting usage collection: ${e}`, { code: 500, log: true });
112
- });
134
+ assistant.log('[authenticated]: Monthly reset completed!');
113
135
  }
@@ -4,7 +4,7 @@ const powertools = require('node-powertools');
4
4
  * Firestore trigger: payments-webhooks/{eventId} onWrite
5
5
  *
6
6
  * Processes pending webhook events:
7
- * 1. Transforms raw provider data into unified subscription object
7
+ * 1. Transforms raw processor data into unified subscription object
8
8
  * 2. Updates the user's subscription in users/{uid}
9
9
  * 3. Stores the subscription doc in payments-subscriptions/{resourceId}
10
10
  * 4. Marks the webhook as completed
@@ -31,12 +31,14 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
31
31
  const raw = dataAfter.raw;
32
32
  const eventType = dataAfter.event?.type;
33
33
 
34
+ assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, uid=${uid || 'null'}`);
35
+
34
36
  // Validate UID
35
37
  if (!uid) {
36
38
  throw new Error('Webhook event has no UID — cannot process');
37
39
  }
38
40
 
39
- // Load and initialize the shared library for this processor
41
+ // Load the shared library for this processor (only needs toUnified, not SDK init)
40
42
  let library;
41
43
  try {
42
44
  library = require(`../../../libraries/${processor}.js`);
@@ -44,12 +46,12 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
44
46
  throw new Error(`Unknown processor library: ${processor}`);
45
47
  }
46
48
 
47
- library.init();
48
-
49
49
  // Extract the subscription object from the raw event
50
50
  // Stripe sends events with event.data.object as the subscription
51
51
  const rawSubscription = raw.data?.object || {};
52
52
 
53
+ assistant.log(`Raw subscription: stripeStatus=${rawSubscription.status}, cancelAtPeriodEnd=${rawSubscription.cancel_at_period_end}, trialEnd=${rawSubscription.trial_end || 'none'}, resourceId=${rawSubscription.id}`);
54
+
53
55
  // Transform raw data into unified subscription object
54
56
  const unified = library.toUnified(rawSubscription, {
55
57
  config: Manager.config,
@@ -57,13 +59,7 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
57
59
  eventId: eventId,
58
60
  });
59
61
 
60
- assistant.log(`Processing webhook ${eventId}:`, {
61
- processor,
62
- uid,
63
- eventType,
64
- productId: unified.product.id,
65
- status: unified.status,
66
- });
62
+ assistant.log(`Unified result: status=${unified.status}, product=${unified.product.id}, frequency=${unified.payment.frequency}, trial.claimed=${unified.trial.claimed}, cancellation.pending=${unified.cancellation.pending}`);
67
63
 
68
64
  // Build timestamps
69
65
  const now = powertools.timestamp(new Date(), { output: 'string' });
@@ -74,6 +70,8 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
74
70
  subscription: unified,
75
71
  }, { merge: true });
76
72
 
73
+ assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
74
+
77
75
  // Write to payments-subscriptions/{resourceId}
78
76
  const resourceId = unified.payment.resourceId;
79
77
  if (resourceId) {
@@ -99,6 +97,10 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
99
97
  },
100
98
  },
101
99
  }, { merge: true });
100
+
101
+ assistant.log(`Updated payments-subscriptions/${resourceId}: uid=${uid}, eventType=${eventType}`);
102
+ } else {
103
+ assistant.log(`No resourceId in unified result, skipping payments-subscriptions write`);
102
104
  }
103
105
 
104
106
  // Mark webhook as completed
@@ -113,9 +115,9 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
113
115
  },
114
116
  }, { merge: true });
115
117
 
116
- assistant.log(`Webhook ${eventId} processed successfully`);
118
+ assistant.log(`Webhook ${eventId} completed: wrote users/${uid}, payments-subscriptions/${resourceId || 'skipped'}, payments-webhooks/${eventId}=completed`);
117
119
  } catch (e) {
118
- assistant.error(`Webhook ${eventId} processing failed:`, e);
120
+ assistant.error(`Webhook ${eventId} failed: ${e.message}`, e);
119
121
 
120
122
  // Mark as failed with error message
121
123
  await webhookRef.set({