backend-manager 5.0.194 → 5.0.196
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 +11 -0
- package/CLAUDE.md +45 -45
- package/README.md +11 -11
- package/package.json +10 -8
- package/src/manager/libraries/disposable-domains.json +1 -0
- package/src/manager/routes/payments/cancel/post.js +1 -1
- package/_zombie-sub-guard-wip/GAP.md +0 -25
- package/_zombie-sub-guard-wip/journey-payments-zombie-sub.js +0 -159
- package/_zombie-sub-guard-wip/on-write.js +0 -442
- package/_zombie-sub-guard-wip/test-accounts.diff +0 -21
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,17 @@ 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.196] - 2026-04-10
|
|
18
|
+
### Changed
|
|
19
|
+
- Moved disposable domain fetch from `prepublishOnly` lifecycle hook to `prepare-package`'s new `hooks.before` config. The fetch now runs on every `npm run prepare` / `npm install` / `npm publish`, so fresh domains land in both the git working tree and the published tarball — no more drift between git and npm.
|
|
20
|
+
- Bumped `prepare-package` devDep to ^2.1.0 (required for hooks support)
|
|
21
|
+
|
|
22
|
+
# [5.0.195] - 2026-04-10
|
|
23
|
+
### Fixed
|
|
24
|
+
- 24-hour cancellation guard in `payments/cancel` was comparing `Date.now()` (milliseconds) against `startDateUNIX` (seconds), producing an "age" of ~56 years for every subscription — guard never fired and users could cancel brand-new subscriptions. Now multiplies `startDateUNIX` by 1000 before subtraction.
|
|
25
|
+
### Changed
|
|
26
|
+
- Standardized CLI examples in `CLAUDE.md` and `README.md` to use `npx mgr` instead of the deprecated `npx bm` alias
|
|
27
|
+
|
|
17
28
|
# [5.0.194] - 2026-04-08
|
|
18
29
|
### Fixed
|
|
19
30
|
- Fix email template data merge: caller's `settings.data` is now deep-merged at root of template data tree, removing the broken `data.` prefix indirection that caused empty order confirmation emails since 5.0.185
|
package/CLAUDE.md
CHANGED
|
@@ -600,30 +600,30 @@ The `POST /admin/post` route creates blog posts via GitHub's API. It handles ima
|
|
|
600
600
|
### Running Tests
|
|
601
601
|
```bash
|
|
602
602
|
# Option 1: Two terminals
|
|
603
|
-
npx
|
|
604
|
-
npx
|
|
603
|
+
npx mgr emulator # Terminal 1 - keeps emulator running
|
|
604
|
+
npx mgr test # Terminal 2 - runs tests
|
|
605
605
|
|
|
606
606
|
# Option 2: Single command (auto-starts emulator)
|
|
607
|
-
npx
|
|
607
|
+
npx mgr test
|
|
608
608
|
```
|
|
609
609
|
|
|
610
610
|
### Log Files
|
|
611
611
|
BEM CLI commands automatically save all output to log files in `functions/` while still streaming to the console:
|
|
612
|
-
- **`functions/serve.log`** — Output from `npx
|
|
612
|
+
- **`functions/serve.log`** — Output from `npx mgr serve` (Firebase serve)
|
|
613
613
|
- **`functions/emulator.log`** — Full emulator output (Firebase emulator + Cloud Functions logs)
|
|
614
614
|
- **`functions/test.log`** — Test runner output (when running against an existing emulator)
|
|
615
|
-
- **`functions/logs.log`** — Cloud Function logs from `npx
|
|
615
|
+
- **`functions/logs.log`** — Cloud Function logs from `npx mgr logs:read` or `npx mgr logs:tail` (raw JSON for `read`, streaming text for `tail`)
|
|
616
616
|
|
|
617
|
-
When `npx
|
|
617
|
+
When `npx mgr test` starts its own emulator, logs go to `emulator.log` (since it delegates to the emulator command). When running against an already-running emulator, logs go to `test.log`.
|
|
618
618
|
|
|
619
619
|
These files are overwritten on each run and are gitignored (`*.log`). Use them to search for errors, debug webhook pipelines, or review full function output after a test run.
|
|
620
620
|
|
|
621
621
|
### Filtering Tests
|
|
622
622
|
```bash
|
|
623
|
-
npx
|
|
624
|
-
npx
|
|
625
|
-
npx
|
|
626
|
-
npx
|
|
623
|
+
npx mgr test rules/ # Run rules tests (both BEM and project)
|
|
624
|
+
npx mgr test bem:rules/ # Only BEM's rules tests
|
|
625
|
+
npx mgr test project:rules/ # Only project's rules tests
|
|
626
|
+
npx mgr test user/ admin/ # Multiple paths
|
|
627
627
|
```
|
|
628
628
|
|
|
629
629
|
### Test Locations
|
|
@@ -716,7 +716,7 @@ assert.fail(message) // Explicit fail
|
|
|
716
716
|
|
|
717
717
|
## Stripe Webhook Forwarding
|
|
718
718
|
|
|
719
|
-
BEM auto-starts Stripe CLI webhook forwarding when running `npx
|
|
719
|
+
BEM auto-starts Stripe CLI webhook forwarding when running `npx mgr serve` or `npx mgr emulator`. This forwards Stripe test webhooks to the local server so the full payment pipeline works end-to-end during development.
|
|
720
720
|
|
|
721
721
|
**Requirements:**
|
|
722
722
|
- `STRIPE_SECRET_KEY` set in `functions/.env`
|
|
@@ -725,7 +725,7 @@ BEM auto-starts Stripe CLI webhook forwarding when running `npx bm serve` or `np
|
|
|
725
725
|
|
|
726
726
|
**Standalone usage:**
|
|
727
727
|
```bash
|
|
728
|
-
npx
|
|
728
|
+
npx mgr stripe
|
|
729
729
|
```
|
|
730
730
|
|
|
731
731
|
If any prerequisite is missing, webhook forwarding is silently skipped with an info message.
|
|
@@ -736,28 +736,28 @@ The forwarding URL is: `http://localhost:{hostingPort}/backend-manager/payments/
|
|
|
736
736
|
|
|
737
737
|
Quick commands for reading/writing Firestore and managing Auth users directly from the terminal. Works in any BEM consumer project (requires `functions/service-account.json` for production, or `--emulator` for local).
|
|
738
738
|
|
|
739
|
-
**IMPORTANT: All CLI commands (`npx mgr ...`
|
|
739
|
+
**IMPORTANT: All CLI commands (`npx mgr ...`) MUST be run from the consumer project's `functions/` subdirectory** (e.g., `cd /path/to/my-project/functions && npx mgr ...`). The `mgr` binary lives in `functions/node_modules/.bin/` — running from the project root or any other directory will fail.
|
|
740
740
|
|
|
741
741
|
### Firestore Commands
|
|
742
742
|
|
|
743
743
|
```bash
|
|
744
|
-
npx
|
|
745
|
-
npx
|
|
746
|
-
npx
|
|
747
|
-
npx
|
|
744
|
+
npx mgr firestore:get <path> # Read a document
|
|
745
|
+
npx mgr firestore:set <path> '<json>' # Write/merge a document
|
|
746
|
+
npx mgr firestore:set <path> '<json>' --no-merge # Overwrite a document entirely
|
|
747
|
+
npx mgr firestore:query <collection> # Query a collection (default limit 25)
|
|
748
748
|
--where "field==value" # Filter (repeatable for AND)
|
|
749
749
|
--orderBy "field:desc" # Sort
|
|
750
750
|
--limit N # Limit results
|
|
751
|
-
npx
|
|
751
|
+
npx mgr firestore:delete <path> # Delete a document (prompts for confirmation)
|
|
752
752
|
```
|
|
753
753
|
|
|
754
754
|
### Auth Commands
|
|
755
755
|
|
|
756
756
|
```bash
|
|
757
|
-
npx
|
|
758
|
-
npx
|
|
759
|
-
npx
|
|
760
|
-
npx
|
|
757
|
+
npx mgr auth:get <uid-or-email> # Get user by UID or email (auto-detected via @)
|
|
758
|
+
npx mgr auth:list [--limit N] [--page-token T] # List users (default 100)
|
|
759
|
+
npx mgr auth:delete <uid-or-email> # Delete user (prompts for confirmation)
|
|
760
|
+
npx mgr auth:set-claims <uid-or-email> '<json>' # Set custom claims
|
|
761
761
|
```
|
|
762
762
|
|
|
763
763
|
### Logs Commands
|
|
@@ -765,21 +765,21 @@ npx bm auth:set-claims <uid-or-email> '<json>' # Set custom claims
|
|
|
765
765
|
Fetch or stream Cloud Function logs from Google Cloud Logging. Requires `gcloud` CLI installed and authenticated. Auto-resolves the project ID from `service-account.json`, `.firebaserc`, or `GCLOUD_PROJECT`.
|
|
766
766
|
|
|
767
767
|
```bash
|
|
768
|
-
npx
|
|
769
|
-
npx
|
|
770
|
-
npx
|
|
771
|
-
npx
|
|
772
|
-
npx
|
|
773
|
-
npx
|
|
774
|
-
npx
|
|
775
|
-
npx
|
|
776
|
-
npx
|
|
777
|
-
npx
|
|
768
|
+
npx mgr logs:read # Read last 1h of logs (default: 300 entries, newest first)
|
|
769
|
+
npx mgr logs:read --fn bm_api # Filter by function name
|
|
770
|
+
npx mgr logs:read --fn bm_api --severity ERROR # Filter by severity (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
771
|
+
npx mgr logs:read --since 2d --limit 100 # Custom time range and limit
|
|
772
|
+
npx mgr logs:read --search "72.134.242.25" # Search textPayload for a string (IP, email, error, etc.)
|
|
773
|
+
npx mgr logs:read --fn bm_authBeforeCreate --search "ian@example.com" --since 7d # Combined filters
|
|
774
|
+
npx mgr logs:read --order asc # Oldest first (default: desc/newest first)
|
|
775
|
+
npx mgr logs:read --filter 'jsonPayload.level="error"' # Raw gcloud filter passthrough
|
|
776
|
+
npx mgr logs:tail # Stream live logs
|
|
777
|
+
npx mgr logs:tail --fn bm_paymentsWebhookOnWrite # Stream filtered live logs
|
|
778
778
|
```
|
|
779
779
|
|
|
780
780
|
Both commands save output to `functions/logs.log` (overwritten on each run). `logs:read` saves raw JSON; `logs:tail` streams text.
|
|
781
781
|
|
|
782
|
-
**Cloud Logs vs Local Logs:** These commands query **production** Google Cloud Logging. For **local/dev** logs, read `functions/serve.log` (from `npx
|
|
782
|
+
**Cloud Logs vs Local Logs:** These commands query **production** Google Cloud Logging. For **local/dev** logs, read `functions/serve.log` (from `npx mgr serve`) or `functions/emulator.log` (from `npx mgr test`) directly — they are plain text files, not gcloud.
|
|
783
783
|
|
|
784
784
|
| Flag | Description | Default | Commands |
|
|
785
785
|
|------|-------------|---------|----------|
|
|
@@ -834,22 +834,22 @@ The `--fn` flag uses the **deployed Cloud Function name**, not the route path.
|
|
|
834
834
|
|
|
835
835
|
```bash
|
|
836
836
|
# Read a user document from production
|
|
837
|
-
npx
|
|
837
|
+
npx mgr firestore:get users/abc123
|
|
838
838
|
|
|
839
839
|
# Write to emulator
|
|
840
|
-
npx
|
|
840
|
+
npx mgr firestore:set users/test123 '{"name":"Test User"}' --emulator
|
|
841
841
|
|
|
842
842
|
# Query with filters
|
|
843
|
-
npx
|
|
843
|
+
npx mgr firestore:query users --where "subscription.status==active" --limit 10
|
|
844
844
|
|
|
845
845
|
# Look up auth user by email
|
|
846
|
-
npx
|
|
846
|
+
npx mgr auth:get user@example.com
|
|
847
847
|
|
|
848
848
|
# Set admin claims
|
|
849
|
-
npx
|
|
849
|
+
npx mgr auth:set-claims user@example.com '{"admin":true}'
|
|
850
850
|
|
|
851
851
|
# Delete from emulator (no confirmation needed)
|
|
852
|
-
npx
|
|
852
|
+
npx mgr firestore:delete users/test123 --emulator
|
|
853
853
|
```
|
|
854
854
|
|
|
855
855
|
## Usage & Rate Limiting
|
|
@@ -1366,7 +1366,7 @@ Campaigns reference segments by SSOT key: `segments: ['subscription_free']`. Aut
|
|
|
1366
1366
|
|
|
1367
1367
|
### Seed Campaigns
|
|
1368
1368
|
|
|
1369
|
-
Created by `npx
|
|
1369
|
+
Created by `npx mgr setup` (idempotent, enforced fields checked every run):
|
|
1370
1370
|
|
|
1371
1371
|
| ID | Type | Description |
|
|
1372
1372
|
|----|------|-------------|
|
|
@@ -1419,7 +1419,7 @@ marketing: {
|
|
|
1419
1419
|
|
|
1420
1420
|
8. **Increment usage before update** - Call `usage.increment()` then `usage.update()`
|
|
1421
1421
|
|
|
1422
|
-
9. **Add Firestore composite indexes for new compound queries** - Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx
|
|
1422
|
+
9. **Add Firestore composite indexes for new compound queries** - Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx mgr setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
|
|
1423
1423
|
|
|
1424
1424
|
## Key Files Reference
|
|
1425
1425
|
|
|
@@ -1463,7 +1463,7 @@ marketing: {
|
|
|
1463
1463
|
```javascript
|
|
1464
1464
|
assistant.isDevelopment() // true when ENVIRONMENT !== 'production' or in emulator
|
|
1465
1465
|
assistant.isProduction() // true when ENVIRONMENT === 'production'
|
|
1466
|
-
assistant.isTesting() // true when running tests (via npx
|
|
1466
|
+
assistant.isTesting() // true when running tests (via npx mgr test)
|
|
1467
1467
|
```
|
|
1468
1468
|
|
|
1469
1469
|
## Model Context Protocol (MCP)
|
|
@@ -1473,7 +1473,7 @@ BEM includes a built-in MCP server that exposes BEM routes as tools for Claude C
|
|
|
1473
1473
|
### Architecture
|
|
1474
1474
|
|
|
1475
1475
|
Two transport modes:
|
|
1476
|
-
- **Stdio** (local): `npx
|
|
1476
|
+
- **Stdio** (local): `npx mgr mcp` — for Claude Code / Claude Desktop
|
|
1477
1477
|
- **Streamable HTTP** (remote): `POST /backend-manager/mcp` — for Claude Chat (stateless, Firebase Functions compatible)
|
|
1478
1478
|
|
|
1479
1479
|
### Available Tools (19)
|
|
@@ -1507,7 +1507,7 @@ Two transport modes:
|
|
|
1507
1507
|
|
|
1508
1508
|
### Hosting Rewrites
|
|
1509
1509
|
|
|
1510
|
-
The `npx
|
|
1510
|
+
The `npx mgr setup` command automatically adds required Firebase Hosting rewrites for MCP OAuth:
|
|
1511
1511
|
```json
|
|
1512
1512
|
{
|
|
1513
1513
|
"source": "{/backend-manager,/backend-manager/**,/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/authorize,/token}",
|
|
@@ -1518,7 +1518,7 @@ The `npx bm setup` command automatically adds required Firebase Hosting rewrites
|
|
|
1518
1518
|
### CLI Usage
|
|
1519
1519
|
|
|
1520
1520
|
```bash
|
|
1521
|
-
npx
|
|
1521
|
+
npx mgr mcp # Start stdio MCP server (for Claude Code)
|
|
1522
1522
|
```
|
|
1523
1523
|
|
|
1524
1524
|
### Claude Code Configuration
|
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ module.exports = function (assistant) {
|
|
|
89
89
|
Run the setup command:
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
npx
|
|
92
|
+
npx mgr setup
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
## Initialization Options
|
|
@@ -802,28 +802,28 @@ BEM includes an integration test framework that runs against the Firebase emulat
|
|
|
802
802
|
|
|
803
803
|
```bash
|
|
804
804
|
# Option 1: Two terminals (recommended for development)
|
|
805
|
-
npx
|
|
806
|
-
npx
|
|
805
|
+
npx mgr emulator # Terminal 1 - keeps emulator running
|
|
806
|
+
npx mgr test # Terminal 2 - runs tests
|
|
807
807
|
|
|
808
808
|
# Option 2: Single command (auto-starts emulator, shuts down after)
|
|
809
|
-
npx
|
|
809
|
+
npx mgr test
|
|
810
810
|
```
|
|
811
811
|
|
|
812
812
|
### Filtering Tests
|
|
813
813
|
|
|
814
814
|
```bash
|
|
815
|
-
npx
|
|
816
|
-
npx
|
|
817
|
-
npx
|
|
818
|
-
npx
|
|
815
|
+
npx mgr test rules/ # Run rules tests (both BEM and project)
|
|
816
|
+
npx mgr test bem:rules/ # Only BEM's rules tests
|
|
817
|
+
npx mgr test project:rules/ # Only project's rules tests
|
|
818
|
+
npx mgr test user/ admin/ # Multiple paths
|
|
819
819
|
```
|
|
820
820
|
|
|
821
821
|
### Log Files
|
|
822
822
|
|
|
823
823
|
BEM CLI commands automatically save output to log files in the project directory:
|
|
824
|
-
- **`emulator.log`** — Full emulator + Cloud Functions output (`npx
|
|
825
|
-
- **`test.log`** — Test runner output (`npx
|
|
826
|
-
- **`logs.log`** — Cloud Function logs (`npx
|
|
824
|
+
- **`emulator.log`** — Full emulator + Cloud Functions output (`npx mgr emulator`)
|
|
825
|
+
- **`test.log`** — Test runner output (`npx mgr test`, when running against an existing emulator)
|
|
826
|
+
- **`logs.log`** — Cloud Function logs (`npx mgr logs:read` or `npx mgr logs:tail`)
|
|
827
827
|
|
|
828
828
|
Logs are overwritten on each run. Use them to debug failing tests or review function output.
|
|
829
829
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-manager",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.196",
|
|
4
4
|
"description": "Quick tools for developing Firebase functions",
|
|
5
5
|
"main": "src/manager/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "node src/manager/index.js",
|
|
14
|
-
"prepublishOnly": "node scripts/update-disposable-domains.js",
|
|
15
14
|
"prepare": "node -e \"require('prepare-package')()\"",
|
|
16
15
|
"prepare:watch": "node -e \"require('prepare-package/watch')()\""
|
|
17
16
|
},
|
|
@@ -44,7 +43,10 @@
|
|
|
44
43
|
"input": "./src_",
|
|
45
44
|
"output": "./dist_",
|
|
46
45
|
"replace": {},
|
|
47
|
-
"type": "copy"
|
|
46
|
+
"type": "copy",
|
|
47
|
+
"hooks": {
|
|
48
|
+
"before": "node scripts/update-disposable-domains.js"
|
|
49
|
+
}
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
50
52
|
"@firebase/rules-unit-testing": "^5.0.0",
|
|
@@ -54,7 +56,7 @@
|
|
|
54
56
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
55
57
|
"@octokit/rest": "^22.0.1",
|
|
56
58
|
"@sendgrid/mail": "^8.1.6",
|
|
57
|
-
"@sentry/node": "^10.
|
|
59
|
+
"@sentry/node": "^10.48.0",
|
|
58
60
|
"body-parser": "^2.2.2",
|
|
59
61
|
"busboy": "^1.6.0",
|
|
60
62
|
"chalk": "^5.6.2",
|
|
@@ -78,7 +80,7 @@
|
|
|
78
80
|
"npm-api": "^1.0.1",
|
|
79
81
|
"pushid": "^1.0.0",
|
|
80
82
|
"sanitize-html": "^2.17.2",
|
|
81
|
-
"stripe": "^22.0.
|
|
83
|
+
"stripe": "^22.0.1",
|
|
82
84
|
"uid-generator": "^2.0.0",
|
|
83
85
|
"uuid": "^13.0.0",
|
|
84
86
|
"wonderful-fetch": "^2.0.5",
|
|
@@ -88,10 +90,10 @@
|
|
|
88
90
|
"yargs": "^18.0.0"
|
|
89
91
|
},
|
|
90
92
|
"devDependencies": {
|
|
91
|
-
"prepare-package": "^2.0
|
|
93
|
+
"prepare-package": "^2.1.0"
|
|
92
94
|
},
|
|
93
95
|
"peerDependencies": {
|
|
94
|
-
"firebase-admin": "^13.
|
|
95
|
-
"firebase-functions": "^7.2.
|
|
96
|
+
"firebase-admin": "^13.8.0",
|
|
97
|
+
"firebase-functions": "^7.2.5"
|
|
96
98
|
}
|
|
97
99
|
}
|
|
@@ -35,7 +35,7 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
35
35
|
// Guard: subscription younger than 24 hours
|
|
36
36
|
const startDateUNIX = subscription.payment?.startDate?.timestampUNIX;
|
|
37
37
|
if (startDateUNIX) {
|
|
38
|
-
const ageMs = Date.now() - startDateUNIX;
|
|
38
|
+
const ageMs = Date.now() - (startDateUNIX * 1000);
|
|
39
39
|
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
|
|
40
40
|
if (ageMs < twentyFourHoursMs) {
|
|
41
41
|
assistant.log(`Cancel rejected: uid=${uid}, subscription is only ${Math.round(ageMs / 1000 / 60)} minutes old`);
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
## BEM Gap: Webhook overwrites user doc with stale subscription data
|
|
2
|
-
|
|
3
|
-
### Problem
|
|
4
|
-
When a non-current subscription is cancelled on the provider (e.g. cancelling a zombie/duplicate PayPal sub), the provider fires a cancellation webhook. BEM processes it, finds the user by UID, and overwrites `subscription.*` on the user doc with the cancelled sub's data — even though the user has a different, active subscription.
|
|
5
|
-
|
|
6
|
-
### Example
|
|
7
|
-
- User has active Chargebee sub (current) + old suspended PayPal sub (zombie)
|
|
8
|
-
- We cancel the PayPal sub → PayPal fires `BILLING.SUBSCRIPTION.CANCELLED`
|
|
9
|
-
- BEM finds user by UID → sets `subscription.status = 'cancelled'`, `subscription.payment.resourceId = <old PayPal sub>`
|
|
10
|
-
- User is now broken — their active Chargebee sub is invisible
|
|
11
|
-
|
|
12
|
-
### Fix needed in `on-write.js`
|
|
13
|
-
Before writing the unified subscription data to the user doc, check:
|
|
14
|
-
```
|
|
15
|
-
if (user.subscription.payment.resourceId !== webhook.resourceId) {
|
|
16
|
-
// This webhook is for a DIFFERENT subscription than the user's current one.
|
|
17
|
-
// Update the payments-orders doc only. Do NOT touch the user doc.
|
|
18
|
-
}
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
### Affected file
|
|
22
|
-
`backend-manager/src/manager/events/firestore/payments-webhooks/on-write.js` — the section that writes to `users/{uid}`
|
|
23
|
-
|
|
24
|
-
### Impact
|
|
25
|
-
Without this fix, any cancellation/suspension of a non-current subscription (duplicate cleanup, provider-side cancellation of old subs) will corrupt the user doc. Currently requires manual restoration after each occurrence.
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test: Payment Journey - Zombie/Stale Subscription Guard
|
|
3
|
-
* Simulates: user has active sub A → webhook arrives for old sub B (cancellation)
|
|
4
|
-
*
|
|
5
|
-
* Verifies that on-write.js does NOT overwrite the user doc when a webhook
|
|
6
|
-
* arrives for a subscription that doesn't match the user's current resourceId.
|
|
7
|
-
* The order doc should still be updated, but user.subscription must remain unchanged.
|
|
8
|
-
*/
|
|
9
|
-
module.exports = {
|
|
10
|
-
description: 'Payment journey: zombie subscription webhook does not overwrite current subscription',
|
|
11
|
-
type: 'suite',
|
|
12
|
-
timeout: 30000,
|
|
13
|
-
|
|
14
|
-
tests: [
|
|
15
|
-
{
|
|
16
|
-
name: 'setup-active-subscription',
|
|
17
|
-
async run({ accounts, firestore, assert, state, config, http, waitFor }) {
|
|
18
|
-
const uid = accounts['journey-payments-zombie-sub'].uid;
|
|
19
|
-
|
|
20
|
-
// Resolve first paid product from config
|
|
21
|
-
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
22
|
-
assert.ok(paidProduct, 'Config should have at least one paid product');
|
|
23
|
-
|
|
24
|
-
state.uid = uid;
|
|
25
|
-
state.paidProductId = paidProduct.id;
|
|
26
|
-
state.paidProductName = paidProduct.name;
|
|
27
|
-
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
28
|
-
|
|
29
|
-
// Create subscription via test intent
|
|
30
|
-
const response = await http.as('journey-payments-zombie-sub').post('payments/intent', {
|
|
31
|
-
processor: 'test',
|
|
32
|
-
productId: paidProduct.id,
|
|
33
|
-
frequency: 'monthly',
|
|
34
|
-
});
|
|
35
|
-
assert.isSuccess(response, 'Intent should succeed');
|
|
36
|
-
state.orderId = response.data.orderId;
|
|
37
|
-
|
|
38
|
-
// Wait for subscription to activate
|
|
39
|
-
await waitFor(async () => {
|
|
40
|
-
const userDoc = await firestore.get(`users/${uid}`);
|
|
41
|
-
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
42
|
-
}, 15000, 500);
|
|
43
|
-
|
|
44
|
-
const userDoc = await firestore.get(`users/${uid}`);
|
|
45
|
-
assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should be ${paidProduct.id}`);
|
|
46
|
-
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
47
|
-
|
|
48
|
-
// Save the current (real) subscription resourceId
|
|
49
|
-
state.currentResourceId = userDoc.subscription.payment.resourceId;
|
|
50
|
-
assert.ok(state.currentResourceId, 'Should have a resourceId');
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
name: 'send-zombie-cancellation-webhook',
|
|
56
|
-
async run({ http, assert, state, config }) {
|
|
57
|
-
// Send a cancellation webhook for a DIFFERENT subscription (the "zombie")
|
|
58
|
-
state.zombieResourceId = 'sub_zombie_old_dead_' + Date.now();
|
|
59
|
-
state.zombieEventId = `_test-evt-zombie-cancel-${Date.now()}`;
|
|
60
|
-
|
|
61
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
62
|
-
id: state.zombieEventId,
|
|
63
|
-
type: 'customer.subscription.deleted',
|
|
64
|
-
data: {
|
|
65
|
-
object: {
|
|
66
|
-
id: state.zombieResourceId,
|
|
67
|
-
object: 'subscription',
|
|
68
|
-
status: 'canceled',
|
|
69
|
-
metadata: { uid: state.uid },
|
|
70
|
-
cancel_at_period_end: false,
|
|
71
|
-
canceled_at: Math.floor(Date.now() / 1000),
|
|
72
|
-
current_period_end: Math.floor(Date.now() / 1000),
|
|
73
|
-
current_period_start: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
74
|
-
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
75
|
-
trial_start: null,
|
|
76
|
-
trial_end: null,
|
|
77
|
-
plan: { product: state.paidStripeProductId, interval: 'month' },
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
assert.isSuccess(response, 'Webhook should be accepted');
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
name: 'user-doc-unchanged',
|
|
88
|
-
async run({ firestore, assert, state, waitFor }) {
|
|
89
|
-
// Wait for the zombie webhook to complete processing
|
|
90
|
-
await waitFor(async () => {
|
|
91
|
-
const doc = await firestore.get(`payments-webhooks/${state.zombieEventId}`);
|
|
92
|
-
return doc?.status === 'completed';
|
|
93
|
-
}, 15000, 500);
|
|
94
|
-
|
|
95
|
-
// Verify the webhook completed but did NOT trigger a transition
|
|
96
|
-
const webhookDoc = await firestore.get(`payments-webhooks/${state.zombieEventId}`);
|
|
97
|
-
assert.equal(webhookDoc.status, 'completed', 'Webhook should complete successfully');
|
|
98
|
-
assert.equal(webhookDoc.transition, null, 'Transition should be null (suppressed for zombie sub)');
|
|
99
|
-
|
|
100
|
-
// Verify user doc was NOT overwritten — subscription should still be active with original resourceId
|
|
101
|
-
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
102
|
-
assert.equal(userDoc.subscription.status, 'active', 'User subscription should still be active');
|
|
103
|
-
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
|
|
104
|
-
assert.equal(userDoc.subscription.payment.resourceId, state.currentResourceId, 'ResourceId should still be the current subscription, not the zombie');
|
|
105
|
-
assert.notEqual(userDoc.subscription.payment.resourceId, state.zombieResourceId, 'ResourceId should NOT be the zombie subscription');
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
|
|
109
|
-
{
|
|
110
|
-
name: 'send-zombie-suspension-webhook',
|
|
111
|
-
async run({ http, assert, state, config }) {
|
|
112
|
-
// Also test that a payment failure for a zombie sub doesn't suspend the user
|
|
113
|
-
state.zombieSuspendEventId = `_test-evt-zombie-suspend-${Date.now()}`;
|
|
114
|
-
|
|
115
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
116
|
-
id: state.zombieSuspendEventId,
|
|
117
|
-
type: 'invoice.payment_failed',
|
|
118
|
-
data: {
|
|
119
|
-
object: {
|
|
120
|
-
id: state.zombieResourceId,
|
|
121
|
-
object: 'subscription',
|
|
122
|
-
status: 'active',
|
|
123
|
-
metadata: { uid: state.uid },
|
|
124
|
-
cancel_at_period_end: false,
|
|
125
|
-
canceled_at: null,
|
|
126
|
-
current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30,
|
|
127
|
-
current_period_start: Math.floor(Date.now() / 1000),
|
|
128
|
-
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
129
|
-
trial_start: null,
|
|
130
|
-
trial_end: null,
|
|
131
|
-
plan: { product: state.paidStripeProductId, interval: 'month' },
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
assert.isSuccess(response, 'Webhook should be accepted');
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
{
|
|
141
|
-
name: 'user-still-active-after-zombie-suspension',
|
|
142
|
-
async run({ firestore, assert, state, waitFor }) {
|
|
143
|
-
await waitFor(async () => {
|
|
144
|
-
const doc = await firestore.get(`payments-webhooks/${state.zombieSuspendEventId}`);
|
|
145
|
-
return doc?.status === 'completed';
|
|
146
|
-
}, 15000, 500);
|
|
147
|
-
|
|
148
|
-
const webhookDoc = await firestore.get(`payments-webhooks/${state.zombieSuspendEventId}`);
|
|
149
|
-
assert.equal(webhookDoc.status, 'completed', 'Webhook should complete');
|
|
150
|
-
assert.equal(webhookDoc.transition, null, 'Transition should be null (suppressed for zombie sub)');
|
|
151
|
-
|
|
152
|
-
// User should STILL be active — zombie payment failure must not suspend them
|
|
153
|
-
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
154
|
-
assert.equal(userDoc.subscription.status, 'active', 'User should still be active after zombie payment failure');
|
|
155
|
-
assert.equal(userDoc.subscription.payment.resourceId, state.currentResourceId, 'ResourceId should still be the current subscription');
|
|
156
|
-
},
|
|
157
|
-
},
|
|
158
|
-
],
|
|
159
|
-
};
|
|
@@ -1,442 +0,0 @@
|
|
|
1
|
-
const powertools = require('node-powertools');
|
|
2
|
-
const transitions = require('./transitions/index.js');
|
|
3
|
-
const { trackPayment } = require('./analytics.js');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Firestore trigger: payments-webhooks/{eventId} onWrite
|
|
7
|
-
*
|
|
8
|
-
* Processes pending webhook events:
|
|
9
|
-
* 1. Loads the processor library
|
|
10
|
-
* 2. Fetches the latest resource from the processor API (not the stale webhook payload)
|
|
11
|
-
* 3. Branches on event.category to transform + write:
|
|
12
|
-
* - subscription → toUnifiedSubscription → users/{uid}.subscription + payments-orders/{orderId}
|
|
13
|
-
* - one-time → toUnifiedOneTime → payments-orders/{orderId}
|
|
14
|
-
* 4. Detects state transitions and dispatches handler files (non-blocking)
|
|
15
|
-
* 5. Marks the webhook as completed
|
|
16
|
-
*/
|
|
17
|
-
module.exports = async ({ assistant, change, context }) => {
|
|
18
|
-
const Manager = assistant.Manager;
|
|
19
|
-
const admin = Manager.libraries.admin;
|
|
20
|
-
|
|
21
|
-
const dataAfter = change.after.data();
|
|
22
|
-
|
|
23
|
-
// Short-circuit: deleted doc or non-pending status
|
|
24
|
-
if (!dataAfter || dataAfter.status !== 'pending') {
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const eventId = context.params.eventId;
|
|
29
|
-
const webhookRef = admin.firestore().doc(`payments-webhooks/${eventId}`);
|
|
30
|
-
|
|
31
|
-
// Set status to processing
|
|
32
|
-
await webhookRef.set({ status: 'processing' }, { merge: true });
|
|
33
|
-
|
|
34
|
-
// Hoisted so orderId is available in catch block for audit trail
|
|
35
|
-
let orderId = null;
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const processor = dataAfter.processor;
|
|
39
|
-
let uid = dataAfter.owner;
|
|
40
|
-
const raw = dataAfter.raw;
|
|
41
|
-
const eventType = dataAfter.event?.type;
|
|
42
|
-
const category = dataAfter.event?.category;
|
|
43
|
-
const resourceType = dataAfter.event?.resourceType;
|
|
44
|
-
const resourceId = dataAfter.event?.resourceId;
|
|
45
|
-
|
|
46
|
-
assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, category=${category}, resourceType=${resourceType}, resourceId=${resourceId}, uid=${uid || 'null'}`);
|
|
47
|
-
|
|
48
|
-
// Validate category
|
|
49
|
-
if (!category) {
|
|
50
|
-
throw new Error(`Webhook event has no category — cannot process`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Load the shared library for this processor
|
|
54
|
-
let library;
|
|
55
|
-
try {
|
|
56
|
-
library = require(`../../../libraries/payment/processors/${processor}.js`);
|
|
57
|
-
} catch (e) {
|
|
58
|
-
throw new Error(`Unknown processor library: ${processor}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Fetch the latest resource from the processor API
|
|
62
|
-
// This ensures we always work with the most current state, not stale webhook data
|
|
63
|
-
const rawFallback = raw.data?.object || {};
|
|
64
|
-
const resource = await library.fetchResource(resourceType, resourceId, rawFallback, { admin, eventType, config: Manager.config });
|
|
65
|
-
|
|
66
|
-
assistant.log(`Fetched resource: type=${resourceType}, id=${resourceId}, status=${resource.status || 'unknown'}`);
|
|
67
|
-
|
|
68
|
-
// Resolve UID from the fetched resource if not available from webhook parse
|
|
69
|
-
// This handles events like PAYMENT.SALE where the Sale object doesn't carry custom_id
|
|
70
|
-
// but the parent subscription (fetched via fetchResource) does
|
|
71
|
-
if (!uid && library.getUid) {
|
|
72
|
-
uid = library.getUid(resource);
|
|
73
|
-
assistant.log(`UID resolved from fetched resource: uid=${uid || 'null'}, processor=${processor}, resourceType=${resourceType}`);
|
|
74
|
-
|
|
75
|
-
// Update the webhook doc with the resolved UID so it's persisted for debugging
|
|
76
|
-
if (uid) {
|
|
77
|
-
await webhookRef.set({ owner: uid }, { merge: true });
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Fallback: resolve UID from the hosted page's pass_thru_content
|
|
82
|
-
// Chargebee hosted page checkouts don't forward subscription[meta_data] to the subscription,
|
|
83
|
-
// but pass_thru_content is stored on the hosted page and contains our UID + orderId
|
|
84
|
-
let resolvedFromPassThru = false;
|
|
85
|
-
let passThruOrderId = null;
|
|
86
|
-
if (!uid && library.resolveUidFromHostedPage) {
|
|
87
|
-
const passThruResult = await library.resolveUidFromHostedPage(resourceId, assistant);
|
|
88
|
-
if (passThruResult) {
|
|
89
|
-
uid = passThruResult.uid;
|
|
90
|
-
passThruOrderId = passThruResult.orderId || null;
|
|
91
|
-
resolvedFromPassThru = true;
|
|
92
|
-
assistant.log(`UID resolved from hosted page pass_thru_content: uid=${uid}, orderId=${passThruOrderId}, resourceId=${resourceId}`);
|
|
93
|
-
|
|
94
|
-
await webhookRef.set({ owner: uid }, { merge: true });
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Validate UID — must have one by now
|
|
99
|
-
if (!uid) {
|
|
100
|
-
throw new Error(`Webhook event has no UID — could not extract from webhook parse, fetched ${resourceType} resource, or hosted page pass_thru_content`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Backfill: if UID was resolved from pass_thru_content, set meta_data on the subscription
|
|
104
|
-
// so future webhooks (renewals, cancellations) can resolve the UID directly
|
|
105
|
-
if (resolvedFromPassThru && resourceType === 'subscription' && library.setMetaData) {
|
|
106
|
-
library.setMetaData(resource, { uid, orderId: passThruOrderId })
|
|
107
|
-
.then(() => assistant.log(`Backfilled meta_data on subscription ${resourceId} + customer: uid=${uid}, orderId=${passThruOrderId}`))
|
|
108
|
-
.catch((e) => assistant.error(`Failed to backfill meta_data on ${resourceType} ${resourceId}: ${e.message}`));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Build timestamps
|
|
112
|
-
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
113
|
-
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
114
|
-
const webhookReceivedUNIX = dataAfter.metadata?.created?.timestampUNIX || nowUNIX;
|
|
115
|
-
|
|
116
|
-
// Extract orderId from resource (processor-agnostic)
|
|
117
|
-
// Falls back to pass_thru_content orderId when meta_data wasn't available on the resource
|
|
118
|
-
orderId = library.getOrderId(resource) || passThruOrderId;
|
|
119
|
-
|
|
120
|
-
// Process the payment event (subscription or one-time)
|
|
121
|
-
if (category !== 'subscription' && category !== 'one-time') {
|
|
122
|
-
throw new Error(`Unknown event category: ${category}`);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw });
|
|
126
|
-
|
|
127
|
-
// Mark webhook as completed (include transition name for auditing/testing)
|
|
128
|
-
await webhookRef.set({
|
|
129
|
-
status: 'completed',
|
|
130
|
-
owner: uid,
|
|
131
|
-
orderId: orderId,
|
|
132
|
-
transition: transitionName,
|
|
133
|
-
metadata: {
|
|
134
|
-
completed: {
|
|
135
|
-
timestamp: now,
|
|
136
|
-
timestampUNIX: nowUNIX,
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
}, { merge: true });
|
|
140
|
-
|
|
141
|
-
assistant.log(`Webhook ${eventId} completed`);
|
|
142
|
-
} catch (e) {
|
|
143
|
-
assistant.error(`Webhook ${eventId} failed: ${e.message}`, e);
|
|
144
|
-
|
|
145
|
-
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
146
|
-
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
147
|
-
|
|
148
|
-
// Mark as failed with error message
|
|
149
|
-
await webhookRef.set({
|
|
150
|
-
status: 'failed',
|
|
151
|
-
error: e.message || String(e),
|
|
152
|
-
}, { merge: true });
|
|
153
|
-
|
|
154
|
-
// Mark intent as failed if we resolved the orderId before the error
|
|
155
|
-
if (orderId) {
|
|
156
|
-
await admin.firestore().doc(`payments-intents/${orderId}`).set({
|
|
157
|
-
status: 'failed',
|
|
158
|
-
error: e.message || String(e),
|
|
159
|
-
metadata: {
|
|
160
|
-
completed: {
|
|
161
|
-
timestamp: now,
|
|
162
|
-
timestampUNIX: nowUNIX,
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
}, { merge: true });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Process a payment event (subscription or one-time)
|
|
172
|
-
* 1. Staleness check
|
|
173
|
-
* 2. Read user doc (for transition detection)
|
|
174
|
-
* 3. Transform raw resource → unified object
|
|
175
|
-
* 4. Build order object
|
|
176
|
-
* 5. Detect and dispatch transition handlers (non-blocking)
|
|
177
|
-
* 6. Track analytics (non-blocking)
|
|
178
|
-
* 7. Write to Firestore (user doc for subscriptions + payments-orders)
|
|
179
|
-
*/
|
|
180
|
-
async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw }) {
|
|
181
|
-
const Manager = assistant.Manager;
|
|
182
|
-
const admin = Manager.libraries.admin;
|
|
183
|
-
const isSubscription = category === 'subscription';
|
|
184
|
-
|
|
185
|
-
// Staleness check: skip if a newer webhook already wrote to this order
|
|
186
|
-
if (orderId) {
|
|
187
|
-
const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
|
|
188
|
-
if (existingDoc.exists) {
|
|
189
|
-
const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
|
|
190
|
-
if (webhookReceivedUNIX < existingUpdatedUNIX) {
|
|
191
|
-
assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Read current user doc (needed for transition detection + handler context)
|
|
198
|
-
const userDoc = await admin.firestore().doc(`users/${uid}`).get();
|
|
199
|
-
const userData = userDoc.exists ? userDoc.data() : {};
|
|
200
|
-
const before = isSubscription ? (userData.subscription || null) : null;
|
|
201
|
-
|
|
202
|
-
assistant.log(`User doc for ${uid}: exists=${userDoc.exists}, email=${userData?.auth?.email || 'null'}, name=${userData?.personal?.name?.first || 'null'}, subscription=${userData?.subscription?.product?.id || 'null'}`);
|
|
203
|
-
|
|
204
|
-
// Auto-fill user name from payment processor if not already set
|
|
205
|
-
if (!userData?.personal?.name?.first) {
|
|
206
|
-
const customerName = extractCustomerName(resource, resourceType);
|
|
207
|
-
if (customerName?.first) {
|
|
208
|
-
await admin.firestore().doc(`users/${uid}`).set({
|
|
209
|
-
personal: { name: customerName },
|
|
210
|
-
}, { merge: true });
|
|
211
|
-
assistant.log(`Auto-filled user name from ${resourceType}: ${customerName.first} ${customerName.last || ''}`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Transform raw resource → unified object
|
|
216
|
-
const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
|
|
217
|
-
const unified = isSubscription
|
|
218
|
-
? library.toUnifiedSubscription(resource, transformOptions)
|
|
219
|
-
: library.toUnifiedOneTime(resource, transformOptions);
|
|
220
|
-
|
|
221
|
-
// Override: immediately suspend on payment denial
|
|
222
|
-
// Processors keep the sub active while retrying, but we revoke access right away.
|
|
223
|
-
// If the retry succeeds (e.g. PAYMENT.SALE.COMPLETED), it will restore active status.
|
|
224
|
-
// PayPal: PAYMENT.SALE.DENIED, Stripe: invoice.payment_failed, Chargebee: payment_failed
|
|
225
|
-
const PAYMENT_DENIED_EVENTS = ['PAYMENT.SALE.DENIED', 'invoice.payment_failed', 'payment_failed'];
|
|
226
|
-
if (isSubscription && PAYMENT_DENIED_EVENTS.includes(eventType) && unified.status === 'active') {
|
|
227
|
-
assistant.log(`Overriding status to suspended: ${eventType} received but provider still says active`);
|
|
228
|
-
unified.status = 'suspended';
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
assistant.log(`Unified ${category}: product=${unified.product.id}, status=${unified.status}`, unified);
|
|
232
|
-
|
|
233
|
-
// Read checkout context from payments-intents (attribution, discount, supplemental)
|
|
234
|
-
let intentData = {};
|
|
235
|
-
if (orderId) {
|
|
236
|
-
const intentDoc = await admin.firestore().doc(`payments-intents/${orderId}`).get();
|
|
237
|
-
intentData = intentDoc.exists ? intentDoc.data() : {};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Build the order object (single source of truth for handlers + Firestore)
|
|
241
|
-
const order = {
|
|
242
|
-
id: orderId,
|
|
243
|
-
type: category,
|
|
244
|
-
owner: uid,
|
|
245
|
-
productId: unified.product.id,
|
|
246
|
-
processor: processor,
|
|
247
|
-
resourceId: resourceId,
|
|
248
|
-
unified: unified,
|
|
249
|
-
attribution: intentData.attribution || {},
|
|
250
|
-
discount: intentData.discount || null,
|
|
251
|
-
supplemental: intentData.supplemental || {},
|
|
252
|
-
metadata: {
|
|
253
|
-
created: {
|
|
254
|
-
timestamp: now,
|
|
255
|
-
timestampUNIX: nowUNIX,
|
|
256
|
-
},
|
|
257
|
-
updated: {
|
|
258
|
-
timestamp: now,
|
|
259
|
-
timestampUNIX: nowUNIX,
|
|
260
|
-
},
|
|
261
|
-
updatedBy: {
|
|
262
|
-
event: {
|
|
263
|
-
name: eventType,
|
|
264
|
-
id: eventId,
|
|
265
|
-
},
|
|
266
|
-
},
|
|
267
|
-
},
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
// Guard: check if this webhook is for the user's current subscription
|
|
271
|
-
const currentResourceId = userData?.subscription?.payment?.resourceId;
|
|
272
|
-
const isCurrentSub = !currentResourceId || currentResourceId === resourceId;
|
|
273
|
-
|
|
274
|
-
// Detect and dispatch transition (non-blocking)
|
|
275
|
-
// Only run transitions for the user's current subscription — zombie webhooks should not trigger emails
|
|
276
|
-
const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
277
|
-
const transitionName = isCurrentSub
|
|
278
|
-
? transitions.detectTransition(category, before, unified, eventType)
|
|
279
|
-
: null;
|
|
280
|
-
|
|
281
|
-
if (!isCurrentSub && isSubscription) {
|
|
282
|
-
const wouldBeTransition = transitions.detectTransition(category, before, unified, eventType);
|
|
283
|
-
if (wouldBeTransition) {
|
|
284
|
-
assistant.log(`Transition suppressed for stale sub: ${category}/${wouldBeTransition} (webhook resourceId=${resourceId} != current resourceId=${currentResourceId})`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (transitionName) {
|
|
289
|
-
assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
|
|
290
|
-
|
|
291
|
-
if (shouldRunHandlers) {
|
|
292
|
-
// Extract unified refund details from the processor library (keeps handlers processor-agnostic)
|
|
293
|
-
const refundDetails = (transitionName === 'payment-refunded' && library.getRefundDetails)
|
|
294
|
-
? library.getRefundDetails(raw)
|
|
295
|
-
: null;
|
|
296
|
-
|
|
297
|
-
transitions.dispatch(transitionName, category, {
|
|
298
|
-
before, after: unified, order, uid, userDoc: userData, assistant, refundDetails,
|
|
299
|
-
});
|
|
300
|
-
} else {
|
|
301
|
-
assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Track payment analytics (non-blocking)
|
|
306
|
-
// Fires independently of transitions — renewals have no transition but still need tracking
|
|
307
|
-
if (shouldRunHandlers) {
|
|
308
|
-
trackPayment({ category, transitionName, eventType, unified, order, uid, processor, assistant });
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Write unified subscription to user doc (subscriptions only)
|
|
312
|
-
if (isSubscription) {
|
|
313
|
-
if (isCurrentSub) {
|
|
314
|
-
await admin.firestore().doc(`users/${uid}`).set({ subscription: unified }, { merge: true });
|
|
315
|
-
assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
|
|
316
|
-
|
|
317
|
-
// Sync marketing contact with updated subscription data (non-blocking)
|
|
318
|
-
if (shouldRunHandlers) {
|
|
319
|
-
const email = Manager.Email(assistant);
|
|
320
|
-
const updatedUserDoc = { ...userData, subscription: unified };
|
|
321
|
-
email.sync(updatedUserDoc)
|
|
322
|
-
.then((r) => assistant.log('Marketing sync after payment:', r))
|
|
323
|
-
.catch((e) => assistant.error('Marketing sync after payment failed:', e));
|
|
324
|
-
}
|
|
325
|
-
} else {
|
|
326
|
-
assistant.log(`Skipping user doc update: webhook resourceId=${resourceId} does not match current subscription resourceId=${currentResourceId}. This is a stale/zombie subscription webhook — only the order doc will be updated.`);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Write to payments-orders/{orderId}
|
|
331
|
-
if (orderId) {
|
|
332
|
-
const orderRef = admin.firestore().doc(`payments-orders/${orderId}`);
|
|
333
|
-
const orderSnap = await orderRef.get();
|
|
334
|
-
|
|
335
|
-
if (!orderSnap.exists) {
|
|
336
|
-
// Initialize requests on first creation only (avoid overwriting cancel/refund data set by endpoints)
|
|
337
|
-
order.requests = {
|
|
338
|
-
cancellation: null,
|
|
339
|
-
refund: null,
|
|
340
|
-
};
|
|
341
|
-
} else {
|
|
342
|
-
// Preserve original created timestamp on subsequent webhook events
|
|
343
|
-
order.metadata.created = orderSnap.data().metadata?.created || order.metadata.created;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
await orderRef.set(order, { merge: true });
|
|
347
|
-
assistant.log(`Updated payments-orders/${orderId}: type=${category}, uid=${uid}, eventType=${eventType}`);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Update payments-intents/{orderId} status to match webhook outcome
|
|
351
|
-
if (orderId) {
|
|
352
|
-
await admin.firestore().doc(`payments-intents/${orderId}`).set({
|
|
353
|
-
status: 'completed',
|
|
354
|
-
metadata: {
|
|
355
|
-
completed: {
|
|
356
|
-
timestamp: now,
|
|
357
|
-
timestampUNIX: nowUNIX,
|
|
358
|
-
},
|
|
359
|
-
},
|
|
360
|
-
}, { merge: true });
|
|
361
|
-
assistant.log(`Updated payments-intents/${orderId}: status=completed`);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Mark abandoned cart as completed (non-blocking, fire-and-forget)
|
|
365
|
-
const { COLLECTION } = require('../../../libraries/abandoned-cart-config.js');
|
|
366
|
-
admin.firestore().doc(`${COLLECTION}/${uid}`).set({
|
|
367
|
-
status: 'completed',
|
|
368
|
-
metadata: {
|
|
369
|
-
updated: {
|
|
370
|
-
timestamp: now,
|
|
371
|
-
timestampUNIX: nowUNIX,
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
}, { merge: true })
|
|
375
|
-
.then(() => assistant.log(`Updated ${COLLECTION}/${uid}: status=completed`))
|
|
376
|
-
.catch((e) => {
|
|
377
|
-
// Ignore not-found — cart may not exist for this user
|
|
378
|
-
if (e.code !== 5) {
|
|
379
|
-
assistant.error(`Failed to update ${COLLECTION}/${uid}: ${e.message}`);
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
return transitionName;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Extract customer name from a raw payment processor resource
|
|
388
|
-
*
|
|
389
|
-
* @param {object} resource - Raw processor resource (Stripe subscription, session, invoice)
|
|
390
|
-
* @param {string} resourceType - 'subscription' | 'session' | 'invoice'
|
|
391
|
-
* @returns {{ first: string, last: string }|null}
|
|
392
|
-
*/
|
|
393
|
-
function extractCustomerName(resource, resourceType) {
|
|
394
|
-
let fullName = null;
|
|
395
|
-
|
|
396
|
-
// Checkout sessions have customer_details.name
|
|
397
|
-
if (resourceType === 'session') {
|
|
398
|
-
fullName = resource.customer_details?.name;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Invoices have customer_name
|
|
402
|
-
if (resourceType === 'invoice') {
|
|
403
|
-
fullName = resource.customer_name;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// PayPal orders have payer.name
|
|
407
|
-
if (resourceType === 'order') {
|
|
408
|
-
const givenName = resource.payer?.name?.given_name;
|
|
409
|
-
const surname = resource.payer?.name?.surname;
|
|
410
|
-
|
|
411
|
-
if (givenName) {
|
|
412
|
-
const { capitalize } = require('../../../libraries/infer-contact.js');
|
|
413
|
-
return {
|
|
414
|
-
first: capitalize(givenName) || null,
|
|
415
|
-
last: capitalize(surname) || null,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Chargebee subscriptions carry shipping_address / billing_address with first_name + last_name
|
|
421
|
-
if (resourceType === 'subscription') {
|
|
422
|
-
const addr = resource.shipping_address || resource.billing_address;
|
|
423
|
-
if (addr?.first_name) {
|
|
424
|
-
const { capitalize } = require('../../../libraries/infer-contact.js');
|
|
425
|
-
return {
|
|
426
|
-
first: capitalize(addr.first_name) || null,
|
|
427
|
-
last: capitalize(addr.last_name) || null,
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (!fullName) {
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const { capitalize } = require('../../../libraries/infer-contact.js');
|
|
437
|
-
const parts = fullName.trim().split(/\s+/);
|
|
438
|
-
return {
|
|
439
|
-
first: capitalize(parts[0]) || null,
|
|
440
|
-
last: capitalize(parts.slice(1).join(' ')) || null,
|
|
441
|
-
};
|
|
442
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
diff --git a/src/test/test-accounts.js b/src/test/test-accounts.js
|
|
2
|
-
index 796f4c3..5b5efc4 100644
|
|
3
|
-
--- a/src/test/test-accounts.js
|
|
4
|
-
+++ b/src/test/test-accounts.js
|
|
5
|
-
@@ -417,6 +417,16 @@ const JOURNEY_ACCOUNTS = {
|
|
6
|
-
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
7
|
-
},
|
|
8
|
-
},
|
|
9
|
-
+ // Journey: zombie/stale subscription guard (webhook for old sub should not overwrite current sub)
|
|
10
|
-
+ 'journey-payments-zombie-sub': {
|
|
11
|
-
+ id: 'journey-payments-zombie-sub',
|
|
12
|
-
+ uid: '_test-journey-payments-zombie-sub',
|
|
13
|
-
+ email: '_test.journey-payments-zombie-sub@{domain}',
|
|
14
|
-
+ properties: {
|
|
15
|
-
+ roles: {},
|
|
16
|
-
+ subscription: { product: { id: 'basic' }, status: 'active' },
|
|
17
|
-
+ },
|
|
18
|
-
+ },
|
|
19
|
-
// Journey: legacy product ID resolution (webhook with legacy product ID maps to correct product)
|
|
20
|
-
'journey-payments-legacy-product': {
|
|
21
|
-
id: 'journey-payments-legacy-product',
|