backend-manager 5.0.84 → 5.0.86
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/CHANGELOG.md +34 -0
- package/CLAUDE.md +66 -3
- package/README.md +7 -5
- package/package.json +5 -4
- package/src/cli/commands/base-command.js +89 -0
- package/src/cli/commands/emulators.js +3 -0
- package/src/cli/commands/serve.js +5 -1
- package/src/cli/commands/stripe.js +14 -0
- package/src/cli/commands/test.js +11 -6
- package/src/cli/index.js +7 -0
- package/src/manager/cron/daily/reset-usage.js +56 -34
- package/src/manager/events/firestore/payments-webhooks/on-write.js +15 -13
- package/src/manager/functions/core/actions/api/user/get-subscription-info.js +1 -1
- package/src/manager/helpers/analytics.js +2 -2
- package/src/manager/helpers/api-manager.js +1 -1
- package/src/manager/helpers/usage.js +51 -3
- package/src/manager/index.js +5 -19
- package/src/manager/libraries/stripe.js +12 -8
- package/src/manager/libraries/test.js +27 -0
- package/src/manager/routes/app/get.js +11 -8
- package/src/manager/routes/payments/intent/post.js +31 -16
- package/src/manager/routes/payments/intent/processors/stripe.js +130 -0
- package/src/manager/routes/payments/intent/processors/test.js +106 -0
- package/src/manager/routes/payments/webhook/post.js +21 -8
- package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
- package/src/manager/routes/payments/webhook/processors/test.js +15 -0
- package/src/manager/routes/user/subscription/get.js +1 -1
- package/src/manager/schemas/payments/webhook/post.js +1 -1
- package/src/test/test-accounts.js +18 -18
- package/templates/_.env +0 -2
- package/templates/backend-manager-config.json +50 -34
- package/test/events/payments/journey-payments-cancel.js +144 -0
- package/test/events/payments/journey-payments-suspend.js +143 -0
- package/test/events/payments/journey-payments-trial.js +120 -0
- package/test/events/payments/journey-payments-upgrade.js +99 -0
- package/test/fixtures/stripe/subscription-active.json +161 -0
- package/test/fixtures/stripe/subscription-canceled.json +161 -0
- package/test/fixtures/stripe/subscription-trialing.json +161 -0
- package/test/functions/user/get-subscription-info.js +2 -2
- package/test/helpers/stripe-to-unified.js +684 -0
- package/test/routes/payments/intent.js +189 -0
- package/test/{payments → routes/payments}/webhook.js +1 -1
- package/test/routes/test/usage.js +7 -6
- package/test/routes/user/subscription.js +2 -2
- package/src/manager/routes/payments/intent/providers/stripe.js +0 -66
- package/test/payments/intent.js +0 -104
- package/test/payments/journey-payment-cancel.js +0 -166
- package/test/payments/journey-payment-suspend.js +0 -162
- package/test/payments/journey-payment-trial.js +0 -167
- 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.
|
|
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
|
-
|
|
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.
|
|
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: {
|
|
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`, `
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
3
|
+
"version": "5.0.86",
|
|
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.
|
|
48
|
-
"@google-cloud/storage": "^7.
|
|
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.
|
|
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;
|
package/src/cli/commands/test.js
CHANGED
|
@@ -102,14 +102,10 @@ class TestCommand extends BaseCommand {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
15
|
-
await
|
|
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
|
|
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
|
-
//
|
|
37
|
-
|
|
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
|
-
//
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
metrics.requests = metrics.requests || 1;
|
|
77
|
+
const metricNames = Object.keys(metrics);
|
|
46
78
|
|
|
47
|
-
|
|
48
|
-
assistant.log('[firestore]: Resetting metrics', metrics);
|
|
79
|
+
assistant.log('[authenticated]: Resetting metrics', metricNames);
|
|
49
80
|
|
|
50
|
-
// Reset
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
//
|
|
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(`[
|
|
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(`[
|
|
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
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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}
|
|
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}
|
|
120
|
+
assistant.error(`Webhook ${eventId} failed: ${e.message}`, e);
|
|
119
121
|
|
|
120
122
|
// Mark as failed with error message
|
|
121
123
|
await webhookRef.set({
|