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 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 bm emulator # Terminal 1 - keeps emulator running
604
- npx bm test # Terminal 2 - runs tests
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 bm test
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 bm serve` (Firebase serve)
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 bm logs:read` or `npx bm logs:tail` (raw JSON for `read`, streaming text for `tail`)
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 bm 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`.
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 bm test rules/ # Run rules tests (both BEM and project)
624
- npx bm test bem:rules/ # Only BEM's rules tests
625
- npx bm test project:rules/ # Only project's rules tests
626
- npx bm test user/ admin/ # Multiple paths
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 bm serve` or `npx bm emulator`. This forwards Stripe test webhooks to the local server so the full payment pipeline works end-to-end during development.
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 bm stripe
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 ...` / `npx bm ...`) 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.
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 bm firestore:get <path> # Read a document
745
- npx bm firestore:set <path> '<json>' # Write/merge a document
746
- npx bm firestore:set <path> '<json>' --no-merge # Overwrite a document entirely
747
- npx bm firestore:query <collection> # Query a collection (default limit 25)
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 bm firestore:delete <path> # Delete a document (prompts for confirmation)
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 bm auth:get <uid-or-email> # Get user by UID or email (auto-detected via @)
758
- npx bm auth:list [--limit N] [--page-token T] # List users (default 100)
759
- npx bm auth:delete <uid-or-email> # Delete user (prompts for confirmation)
760
- npx bm auth:set-claims <uid-or-email> '<json>' # Set custom claims
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 bm logs:read # Read last 1h of logs (default: 300 entries, newest first)
769
- npx bm logs:read --fn bm_api # Filter by function name
770
- npx bm logs:read --fn bm_api --severity ERROR # Filter by severity (DEBUG, INFO, WARNING, ERROR, CRITICAL)
771
- npx bm logs:read --since 2d --limit 100 # Custom time range and limit
772
- npx bm logs:read --search "72.134.242.25" # Search textPayload for a string (IP, email, error, etc.)
773
- npx bm logs:read --fn bm_authBeforeCreate --search "ian@example.com" --since 7d # Combined filters
774
- npx bm logs:read --order asc # Oldest first (default: desc/newest first)
775
- npx bm logs:read --filter 'jsonPayload.level="error"' # Raw gcloud filter passthrough
776
- npx bm logs:tail # Stream live logs
777
- npx bm logs:tail --fn bm_paymentsWebhookOnWrite # Stream filtered live logs
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 bm serve`) or `functions/emulator.log` (from `npx bm test`) directly — they are plain text files, not gcloud.
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 bm firestore:get users/abc123
837
+ npx mgr firestore:get users/abc123
838
838
 
839
839
  # Write to emulator
840
- npx bm firestore:set users/test123 '{"name":"Test User"}' --emulator
840
+ npx mgr firestore:set users/test123 '{"name":"Test User"}' --emulator
841
841
 
842
842
  # Query with filters
843
- npx bm firestore:query users --where "subscription.status==active" --limit 10
843
+ npx mgr firestore:query users --where "subscription.status==active" --limit 10
844
844
 
845
845
  # Look up auth user by email
846
- npx bm auth:get user@example.com
846
+ npx mgr auth:get user@example.com
847
847
 
848
848
  # Set admin claims
849
- npx bm auth:set-claims user@example.com '{"admin":true}'
849
+ npx mgr auth:set-claims user@example.com '{"admin":true}'
850
850
 
851
851
  # Delete from emulator (no confirmation needed)
852
- npx bm firestore:delete users/test123 --emulator
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 bm setup` (idempotent, enforced fields checked every run):
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 bm setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
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 bm test)
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 bm mcp` — for Claude Code / Claude Desktop
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 bm setup` command automatically adds required Firebase Hosting rewrites for MCP OAuth:
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 bm mcp # Start stdio MCP server (for Claude Code)
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 bm setup
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 bm emulator # Terminal 1 - keeps emulator running
806
- npx bm test # Terminal 2 - runs tests
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 bm test
809
+ npx mgr test
810
810
  ```
811
811
 
812
812
  ### Filtering Tests
813
813
 
814
814
  ```bash
815
- npx bm test rules/ # Run rules tests (both BEM and project)
816
- npx bm test bem:rules/ # Only BEM's rules tests
817
- npx bm test project:rules/ # Only project's rules tests
818
- npx bm test user/ admin/ # Multiple paths
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 bm emulator`)
825
- - **`test.log`** — Test runner output (`npx bm test`, when running against an existing emulator)
826
- - **`logs.log`** — Cloud Function logs (`npx bm logs:read` or `npx bm logs:tail`)
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.194",
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.47.0",
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.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.8"
93
+ "prepare-package": "^2.1.0"
92
94
  },
93
95
  "peerDependencies": {
94
- "firebase-admin": "^13.7.0",
95
- "firebase-functions": "^7.2.3"
96
+ "firebase-admin": "^13.8.0",
97
+ "firebase-functions": "^7.2.5"
96
98
  }
97
99
  }
@@ -3152,6 +3152,7 @@
3152
3152
  "moneypipe.net",
3153
3153
  "mongrec.top",
3154
3154
  "monmail.fr.nf",
3155
+ "monomoho.site",
3155
3156
  "monumentmail.com",
3156
3157
  "moolee.net",
3157
3158
  "moonapps.org",
@@ -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',