backend-manager 5.9.3 → 5.9.5
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 +13 -0
- package/CLAUDE.md +6 -0
- package/PROGRESS.md +11 -4
- package/package.json +1 -1
- package/plans/suspended-subscription-dead-zone.md +97 -0
- package/src/cli/commands/install.js +4 -3
- package/src/cli/commands/setup-tests/backend-manager.js +2 -1
- package/src/cli/commands/setup-tests/firebase-admin.js +2 -1
- package/src/cli/commands/setup-tests/firebase-functions.js +2 -1
- package/src/cli/utils/safe-install.js +13 -0
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +20 -3
- package/src/manager/routes/general/email/templates/general/download-app-link.js +20 -3
- package/src/manager/routes/payments/cancel/post.js +22 -2
- package/src/test/test-accounts.js +9 -0
- package/test/routes/payments/cancel.js +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,19 @@ 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.9.5] - 2026-06-21
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Suspended subscription cancel dead zone.** Users with `subscription.status: 'suspended'` were trapped — the subscribe endpoint required them to cancel first, but the cancel endpoint only accepted `active` subscriptions. Cancel now accepts both `active` and `suspended`. When the processor rejects a suspended cancel (subscription already dead on their end), falls back to directly resetting the user doc to `cancelled`.
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
- **Supply-chain audited installs.** All `npm install` calls in CLI commands (`install`, setup-tests) now route through `safeInstall()` which prefixes Socket Firewall (`sfw`) when available, blocking confirmed malware before packages reach disk.
|
|
24
|
+
|
|
25
|
+
# [5.9.4] - 2026-06-21
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **Download-app-link email had blank body.** Template passed `data: {}` so the card rendered logo + signoff + footer but no content. Now populates title, message, and "Download Now" button linking to `{brand.url}/download`.
|
|
29
|
+
|
|
17
30
|
# [5.9.3] - 2026-06-21
|
|
18
31
|
|
|
19
32
|
### Changed
|
package/CLAUDE.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> **Note for contributors and Claude:** This file is the architectural overview — identity, top-level conventions, and a map to deep references. The **meat** (per-subsystem APIs, behavior tables, recipes) lives in `docs/<topic>.md`. When extending or adding content, write it in the matching `docs/*.md` file and cross-link from here — do NOT inline it. If a topic doesn't have a doc yet, create one. Goal: keep this file under 250 lines.
|
|
4
4
|
|
|
5
|
+
> **Mirrored structure:** BEM, UJM, BXM, and EM CLAUDE.md files mirror each other — shared sections (Supply-Chain Security, Development Workflow, File Conventions, etc.) appear in the **same order at the same position** across all four. When adding a section that applies to multiple frameworks, insert it in the same spot in all of them.
|
|
6
|
+
|
|
5
7
|
## Identity
|
|
6
8
|
|
|
7
9
|
Backend Manager (BEM) is a comprehensive framework for building modern Firebase Cloud Functions backends. Sister project to Electron Manager (EM), Browser Extension Manager (BXM), and Ultimate Jekyll Manager (UJM). Provides a single `Manager.init(exports, {...})` bootstrap that wires built-in functions (`bm_api`, auth events, cron jobs), helper classes (Assistant, User, Analytics, Usage, Middleware, Settings, Utilities, Metadata), payment processor integrations (Stripe / PayPal), Firestore-trigger pipelines, marketing campaign automation, an MCP server, and a CLI for emulator/deploy/logs/auth/Firestore operations.
|
|
@@ -94,6 +96,10 @@ See [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) and [docs/cli-logs.
|
|
|
94
96
|
- **If the user reports an error**, check the emulator/test output for the root cause before guessing.
|
|
95
97
|
- **Live-test UI changes via CDP.** When working on admin dashboards or browser-facing endpoints, use the `chrome-devtools` MCP tools (screenshots, click, evaluate JS, console logs) to verify the change works in the running browser. See `~/.claude/mcp-server/servers/chrome-devtools/CLAUDE.md`.
|
|
96
98
|
|
|
99
|
+
## Supply-Chain Security
|
|
100
|
+
|
|
101
|
+
All `npm install` calls in CLI commands (`npx mgr i`, `npx mgr setup`, setup-tests) route through the `safeInstall()` helper (`src/cli/utils/safe-install.js`). It prefixes `sfw` (Socket Firewall) when installed — blocking confirmed malware at the network level before packages reach disk. Falls back to plain npm if sfw isn't available. CI workflows install sfw globally and run `sfw npm install`/`sfw npm ci`. Installs will **fail if sfw detects confirmed malware** in any package in the dependency tree; non-critical CVEs and quality warnings pass through.
|
|
102
|
+
|
|
97
103
|
## File Conventions
|
|
98
104
|
|
|
99
105
|
- **CommonJS** throughout. `prepare-package` copies `src/` → `dist/` 1:1 (no transforms).
|
package/PROGRESS.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
> Agents and maintainers should update this file regularly to reflect the current state of the project.
|
|
3
3
|
|
|
4
4
|
## 🎯 Current Focus
|
|
5
|
-
* **Goal:**
|
|
6
|
-
* **Current Phase:**
|
|
5
|
+
* **Goal:** Fix suspended subscription dead zone — users with `suspended` status couldn't cancel or re-subscribe
|
|
6
|
+
* **Current Phase:** Fix applied + tested (24/24 passing), pending commit + publish
|
|
7
7
|
* **Priority:** High
|
|
8
|
-
* **Last Updated:** 2026-06-
|
|
9
|
-
* **Notes:**
|
|
8
|
+
* **Last Updated:** 2026-06-21 5:50 PM PDT
|
|
9
|
+
* **Notes:** Cancel endpoint now accepts `suspended` subscriptions. Added fallback: if processor rejects (subscription already dead on their end), directly resets user doc to `cancelled`. Traced from live Somiibo user `t9AeAe7QUhNXAUYRV1vUbOU0QVV2` stuck in limbo. Needs BEM publish + Somiibo deploy.
|
|
10
10
|
|
|
11
11
|
## 📌 Active Task List
|
|
12
12
|
* [ ] Phase 6: Setup scaffolds essential configs for fresh projects
|
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
* [ ] Task 5.17: Publish BEM with feed support
|
|
44
44
|
* [ ] Task 5.12: Publish BEM with feed support
|
|
45
45
|
* [ ] Task 5.13: Configure consumer project(s) with `$feed:` sources
|
|
46
|
+
* [x] Phase 7: Fix suspended subscription cancel dead zone
|
|
47
|
+
* [x] Task 7.1: Trace user `t9AeAe7QUhNXAUYRV1vUbOU0QVV2` — identified asymmetric status gates
|
|
48
|
+
* [x] Task 7.2: Update cancel gate to accept `suspended` alongside `active`
|
|
49
|
+
* [x] Task 7.3: Add fallback — direct Firestore reset when processor rejects suspended cancel
|
|
50
|
+
* [x] Task 7.4: Add `cancel-suspended` test account + `allows-suspended-subscription` test
|
|
51
|
+
* [x] Task 7.5: All 24 payment tests passing
|
|
52
|
+
* [ ] Task 7.6: Publish BEM + deploy Somiibo
|
|
46
53
|
* [ ] Phase 3: Post-audit bug fixes
|
|
47
54
|
* [x] Newsletter ReferenceError: `beehiivConfig` → `newsletterRoleConfig` (committed v5.7.1)
|
|
48
55
|
* [x] HTTPS proxy: `serve.js` returns boolean, caller uses `httpsReady` not `httpsEnabled`
|
package/package.json
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Suspended Subscription Dead Zone
|
|
2
|
+
|
|
3
|
+
## Issue
|
|
4
|
+
|
|
5
|
+
A user with a `suspended` subscription was trapped in a loop:
|
|
6
|
+
- **Trying to subscribe** → "You already have a subscription. Please cancel your existing subscription before purchasing a new one." (400)
|
|
7
|
+
- **Trying to cancel** → "No active paid subscription found" (400)
|
|
8
|
+
|
|
9
|
+
No way out.
|
|
10
|
+
|
|
11
|
+
## Affected User
|
|
12
|
+
|
|
13
|
+
- **UID:** `t9AeAe7QUhNXAUYRV1vUbOU0QVV2`
|
|
14
|
+
- **Brand:** Somiibo
|
|
15
|
+
|
|
16
|
+
The user had two stale subscriptions from different processors:
|
|
17
|
+
|
|
18
|
+
| Field | Processor | Status | Resource ID | Last Webhook |
|
|
19
|
+
|---|---|---|---|---|
|
|
20
|
+
| `subscription` | PayPal | `suspended` | `I-Y41DMAGNWGP1` | `BILLING.SUBSCRIPTION.SUSPENDED` (2026-04-19) |
|
|
21
|
+
| `plan` (legacy) | Chargebee | `suspended` | `169xhEU1pYXAt3dsj` | `subscription-profile-fixer` (2024-02-28) |
|
|
22
|
+
|
|
23
|
+
PayPal suspended the subscription (likely failed payment) but never sent a `BILLING.SUBSCRIPTION.CANCELLED` webhook. PayPal doesn't auto-cancel suspended subscriptions — it leaves them in limbo until the user resolves payment or manually cancels in PayPal. The local status stayed `suspended` forever.
|
|
24
|
+
|
|
25
|
+
## Root Cause
|
|
26
|
+
|
|
27
|
+
Asymmetric status gates between the intent and cancel endpoints:
|
|
28
|
+
|
|
29
|
+
### Intent gate (`POST /payments/intent`)
|
|
30
|
+
```js
|
|
31
|
+
// Blocked if product is NOT basic AND status is NOT cancelled
|
|
32
|
+
if (subProductId !== 'basic' && subStatus !== 'cancelled') { ... }
|
|
33
|
+
```
|
|
34
|
+
- `active` → blocked (correct)
|
|
35
|
+
- `suspended` → blocked (correct — user should cancel first)
|
|
36
|
+
- `cancelled` → allowed (correct)
|
|
37
|
+
|
|
38
|
+
### Cancel gate (`POST /payments/cancel`)
|
|
39
|
+
```js
|
|
40
|
+
// Blocked if status is NOT active
|
|
41
|
+
if (subscription.status !== 'active') { ... }
|
|
42
|
+
```
|
|
43
|
+
- `active` → allowed (correct)
|
|
44
|
+
- `suspended` → **blocked** (the bug)
|
|
45
|
+
- `cancelled` → blocked (correct — nothing to cancel)
|
|
46
|
+
|
|
47
|
+
The intent gate treats `suspended` as "has a subscription" (must cancel first), but the cancel gate treats `suspended` as "no subscription to cancel." Dead zone.
|
|
48
|
+
|
|
49
|
+
## The Fix
|
|
50
|
+
|
|
51
|
+
### 1. Cancel gate now accepts `suspended`
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
// Before
|
|
55
|
+
if (!subscription || subscription.status !== 'active' || ...)
|
|
56
|
+
|
|
57
|
+
// After
|
|
58
|
+
if (!subscription || (subscription.status !== 'active' && subscription.status !== 'suspended') || ...)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Fallback for dead processor subscriptions
|
|
62
|
+
|
|
63
|
+
When cancelling a `suspended` subscription, the processor API call might fail — the subscription could be expired/deleted on the processor's side while our local state is stale. The cancel endpoint now catches processor errors for suspended subscriptions and directly resets the user doc:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
if (subscription.status === 'suspended') {
|
|
67
|
+
// Processor rejected — subscription is already dead on their end
|
|
68
|
+
// Directly reset the user doc to cancelled
|
|
69
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
70
|
+
subscription: {
|
|
71
|
+
status: 'cancelled',
|
|
72
|
+
product: { id: 'basic', name: 'Basic' },
|
|
73
|
+
cancellation: { pending: false, date: { timestamp, timestampUNIX } },
|
|
74
|
+
},
|
|
75
|
+
}, { merge: true });
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 3. New test coverage
|
|
80
|
+
|
|
81
|
+
- Test account `cancel-suspended` with `status: 'suspended'` and test processor payment details
|
|
82
|
+
- Test `allows-suspended-subscription` verifying the cancel endpoint accepts and processes suspended subscriptions
|
|
83
|
+
|
|
84
|
+
## Status Matrix (after fix)
|
|
85
|
+
|
|
86
|
+
| Status | Can subscribe? | Can cancel? | Can delete account? |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `active` (basic) | Yes | No (nothing to cancel) | Yes |
|
|
89
|
+
| `active` (paid) | No (cancel first) | Yes | No |
|
|
90
|
+
| `suspended` | No (cancel first) | **Yes** (fixed) | No |
|
|
91
|
+
| `cancelled` | Yes | No (already cancelled) | Yes |
|
|
92
|
+
|
|
93
|
+
## Files Changed
|
|
94
|
+
|
|
95
|
+
- `src/manager/routes/payments/cancel/post.js` — gate + fallback
|
|
96
|
+
- `src/test/test-accounts.js` — `cancel-suspended` account
|
|
97
|
+
- `test/routes/payments/cancel.js` — `allows-suspended-subscription` test
|
|
@@ -5,6 +5,7 @@ const powertools = require('node-powertools');
|
|
|
5
5
|
const Npm = require('npm-api');
|
|
6
6
|
const jetpack = require('fs-jetpack');
|
|
7
7
|
const wonderfulVersion = require('wonderful-version');
|
|
8
|
+
const { safeInstall } = require('../utils/safe-install');
|
|
8
9
|
|
|
9
10
|
class InstallCommand extends BaseCommand {
|
|
10
11
|
async execute(type) {
|
|
@@ -110,12 +111,12 @@ class InstallCommand extends BaseCommand {
|
|
|
110
111
|
const command = name;
|
|
111
112
|
this.log('Running ', command);
|
|
112
113
|
|
|
113
|
-
return await
|
|
114
|
+
return await safeInstall(command, { log: true })
|
|
114
115
|
.catch((e) => {
|
|
115
116
|
throw e;
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
+
|
|
119
120
|
if (name.indexOf('file:') > -1) {
|
|
120
121
|
v = '';
|
|
121
122
|
} else if (!version) {
|
|
@@ -133,7 +134,7 @@ class InstallCommand extends BaseCommand {
|
|
|
133
134
|
const command = `npm i ${name}${v}${t}`;
|
|
134
135
|
this.log('Running ', command);
|
|
135
136
|
|
|
136
|
-
return await
|
|
137
|
+
return await safeInstall(command, { log: true })
|
|
137
138
|
.catch((e) => {
|
|
138
139
|
throw e;
|
|
139
140
|
});
|
|
@@ -4,6 +4,7 @@ const wonderfulVersion = require('wonderful-version');
|
|
|
4
4
|
const powertools = require('node-powertools');
|
|
5
5
|
const Npm = require('npm-api');
|
|
6
6
|
const helpers = require('./helpers');
|
|
7
|
+
const { safeInstall } = require('../../utils/safe-install');
|
|
7
8
|
|
|
8
9
|
class BackendManagerTest extends BaseTest {
|
|
9
10
|
getName() {
|
|
@@ -72,7 +73,7 @@ class BackendManagerTest extends BaseTest {
|
|
|
72
73
|
console.log('Running ', command);
|
|
73
74
|
|
|
74
75
|
// Execute
|
|
75
|
-
await
|
|
76
|
+
await safeInstall(command);
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -3,6 +3,7 @@ const chalk = require('chalk').default;
|
|
|
3
3
|
const wonderfulVersion = require('wonderful-version');
|
|
4
4
|
const powertools = require('node-powertools');
|
|
5
5
|
const helpers = require('./helpers');
|
|
6
|
+
const { safeInstall } = require('../../utils/safe-install');
|
|
6
7
|
|
|
7
8
|
class FirebaseAdminTest extends BaseTest {
|
|
8
9
|
getName() {
|
|
@@ -64,7 +65,7 @@ class FirebaseAdminTest extends BaseTest {
|
|
|
64
65
|
console.log('Running ', command);
|
|
65
66
|
|
|
66
67
|
// Execute
|
|
67
|
-
await
|
|
68
|
+
await safeInstall(command);
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -3,6 +3,7 @@ const chalk = require('chalk').default;
|
|
|
3
3
|
const wonderfulVersion = require('wonderful-version');
|
|
4
4
|
const powertools = require('node-powertools');
|
|
5
5
|
const helpers = require('./helpers');
|
|
6
|
+
const { safeInstall } = require('../../utils/safe-install');
|
|
6
7
|
|
|
7
8
|
class FirebaseFunctionsTest extends BaseTest {
|
|
8
9
|
getName() {
|
|
@@ -64,7 +65,7 @@ class FirebaseFunctionsTest extends BaseTest {
|
|
|
64
65
|
console.log('Running ', command);
|
|
65
66
|
|
|
66
67
|
// Execute
|
|
67
|
-
await
|
|
68
|
+
await safeInstall(command);
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const { execute } = require('node-powertools');
|
|
2
|
+
|
|
3
|
+
let _hasSfw;
|
|
4
|
+
async function safeInstall(command, options) {
|
|
5
|
+
if (_hasSfw === undefined) {
|
|
6
|
+
_hasSfw = await execute('sfw --version', { log: false }).then(() => true).catch(() => false);
|
|
7
|
+
}
|
|
8
|
+
const isInstall = /^npm\s+(install|i)\b/.test(command);
|
|
9
|
+
const prefix = (_hasSfw && isInstall) ? 'sfw ' : '';
|
|
10
|
+
return execute(`${prefix}${command}`, options || { log: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { safeInstall };
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
module.exports = function (payload, config) {
|
|
2
|
+
const brandName = config.brand.name || '';
|
|
3
|
+
const brandUrl = config.brand.url || '';
|
|
4
|
+
const downloadUrl = `${brandUrl}/download`;
|
|
5
|
+
const name = payload.name || '';
|
|
6
|
+
|
|
2
7
|
return {
|
|
3
8
|
// spamFilter: {
|
|
4
9
|
// ip: 3,
|
|
@@ -8,14 +13,26 @@ module.exports = function (payload, config) {
|
|
|
8
13
|
payload: {
|
|
9
14
|
to: {
|
|
10
15
|
email: payload.email,
|
|
11
|
-
name:
|
|
16
|
+
name: name,
|
|
12
17
|
},
|
|
13
18
|
sender: 'marketing',
|
|
14
19
|
categories: ['download'],
|
|
15
|
-
subject:
|
|
20
|
+
subject: `${name || 'Hey'}, your ${brandName} download link is ready!`,
|
|
16
21
|
template: 'card',
|
|
17
22
|
copy: false,
|
|
18
|
-
data: {
|
|
23
|
+
data: {
|
|
24
|
+
email: {
|
|
25
|
+
preview: `Your ${brandName} download link is inside`,
|
|
26
|
+
},
|
|
27
|
+
content: {
|
|
28
|
+
title: 'Your download link is ready!',
|
|
29
|
+
message: `Thanks for your interest in **${brandName}**! Click the button below to head to the download page and get started.`,
|
|
30
|
+
button: {
|
|
31
|
+
url: downloadUrl,
|
|
32
|
+
text: 'Download Now',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
19
36
|
}
|
|
20
37
|
}
|
|
21
38
|
}
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* Sends a download link for the app
|
|
4
4
|
*/
|
|
5
5
|
module.exports = function (payload, config) {
|
|
6
|
+
const brandName = config.brand.name || '';
|
|
7
|
+
const brandUrl = config.brand.url || '';
|
|
8
|
+
const downloadUrl = `${brandUrl}/download`;
|
|
9
|
+
const name = payload.name || '';
|
|
10
|
+
|
|
6
11
|
return {
|
|
7
12
|
// spamFilter: {
|
|
8
13
|
// ip: 3,
|
|
@@ -12,14 +17,26 @@ module.exports = function (payload, config) {
|
|
|
12
17
|
payload: {
|
|
13
18
|
to: {
|
|
14
19
|
email: payload.email,
|
|
15
|
-
name:
|
|
20
|
+
name: name,
|
|
16
21
|
},
|
|
17
22
|
sender: 'marketing',
|
|
18
23
|
categories: ['download'],
|
|
19
|
-
subject: `${
|
|
24
|
+
subject: `${name || 'Hey'}, your ${brandName} download link is ready!`,
|
|
20
25
|
template: 'card',
|
|
21
26
|
copy: false,
|
|
22
|
-
data: {
|
|
27
|
+
data: {
|
|
28
|
+
email: {
|
|
29
|
+
preview: `Your ${brandName} download link is inside`,
|
|
30
|
+
},
|
|
31
|
+
content: {
|
|
32
|
+
title: 'Your download link is ready!',
|
|
33
|
+
message: `Thanks for your interest in **${brandName}**! Click the button below to head to the download page and get started.`,
|
|
34
|
+
button: {
|
|
35
|
+
url: downloadUrl,
|
|
36
|
+
text: 'Download Now',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
23
40
|
}
|
|
24
41
|
}
|
|
25
42
|
}
|
|
@@ -26,8 +26,8 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
26
26
|
|
|
27
27
|
const subscription = user.subscription;
|
|
28
28
|
|
|
29
|
-
// Require an active
|
|
30
|
-
if (!subscription || subscription.status !== 'active' || subscription.product?.id === 'basic') {
|
|
29
|
+
// Require an active or suspended paid subscription
|
|
30
|
+
if (!subscription || (subscription.status !== 'active' && subscription.status !== 'suspended') || subscription.product?.id === 'basic') {
|
|
31
31
|
assistant.log(`Cancel rejected: uid=${uid}, status=${subscription?.status}, product=${subscription?.product?.id}`);
|
|
32
32
|
return assistant.respond('No active paid subscription found', { code: 400 });
|
|
33
33
|
}
|
|
@@ -69,6 +69,26 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
69
69
|
try {
|
|
70
70
|
await processorModule.cancelAtPeriodEnd({ resourceId, uid, subscription, assistant });
|
|
71
71
|
} catch (e) {
|
|
72
|
+
// If the subscription is suspended and the processor rejects (subscription already dead/gone),
|
|
73
|
+
// directly reset the user's subscription to cancelled so they can re-subscribe
|
|
74
|
+
if (subscription.status === 'suspended') {
|
|
75
|
+
assistant.log(`Processor cancel failed for suspended subscription (${e.message}), resetting directly`);
|
|
76
|
+
const admin = assistant.Manager.libraries.admin;
|
|
77
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
78
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
79
|
+
|
|
80
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
81
|
+
subscription: {
|
|
82
|
+
status: 'cancelled',
|
|
83
|
+
product: { id: 'basic', name: 'Basic' },
|
|
84
|
+
cancellation: { pending: false, date: { timestamp: now, timestampUNIX: nowUNIX } },
|
|
85
|
+
},
|
|
86
|
+
}, { merge: true });
|
|
87
|
+
|
|
88
|
+
assistant.log(`Directly cancelled suspended subscription for uid=${uid}`);
|
|
89
|
+
return assistant.respond({ success: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
assistant.log(`Failed to cancel subscription via ${processor}: ${e.message}`);
|
|
73
93
|
return assistant.respond(`Failed to cancel subscription: ${e.message}`, { code: 500, sentry: true });
|
|
74
94
|
}
|
|
@@ -416,6 +416,15 @@ const JOURNEY_ACCOUNTS = {
|
|
|
416
416
|
subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: { timestamp: new Date().toISOString(), timestampUNIX: Math.floor(Date.now() / 1000) } } },
|
|
417
417
|
},
|
|
418
418
|
},
|
|
419
|
+
'cancel-suspended': {
|
|
420
|
+
id: 'cancel-suspended',
|
|
421
|
+
uid: '_test-cancel-suspended',
|
|
422
|
+
email: '_test.cancel-suspended@{domain}',
|
|
423
|
+
properties: {
|
|
424
|
+
roles: {},
|
|
425
|
+
subscription: { product: { id: 'premium', name: 'Premium' }, status: 'suspended', expires: getPastExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_suspended', startDate: getPastExpires() } },
|
|
426
|
+
},
|
|
427
|
+
},
|
|
419
428
|
// Dedicated accounts for portal validation tests
|
|
420
429
|
'portal-no-processor': {
|
|
421
430
|
id: 'portal-no-processor',
|
|
@@ -90,6 +90,29 @@ module.exports = {
|
|
|
90
90
|
},
|
|
91
91
|
},
|
|
92
92
|
|
|
93
|
+
{
|
|
94
|
+
name: 'allows-suspended-subscription',
|
|
95
|
+
async run({ http, assert, config, accounts, firestore, waitFor, skip }) {
|
|
96
|
+
const uid = accounts['cancel-suspended'].uid;
|
|
97
|
+
|
|
98
|
+
const response = await http.as('cancel-suspended').post('backend-manager/payments/cancel', {
|
|
99
|
+
confirmed: true,
|
|
100
|
+
reason: 'Subscription was suspended',
|
|
101
|
+
skipGuards: true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.isSuccess(response, 'Should allow cancelling a suspended subscription');
|
|
105
|
+
assert.equal(response.data.success, true, 'Should return success: true');
|
|
106
|
+
|
|
107
|
+
// Verify the subscription was reset to cancelled (via fallback or webhook)
|
|
108
|
+
await waitFor(async () => {
|
|
109
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
110
|
+
return userDoc?.subscription?.status === 'cancelled'
|
|
111
|
+
|| userDoc?.subscription?.cancellation?.pending === true;
|
|
112
|
+
}, 15000, 500);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
93
116
|
{
|
|
94
117
|
name: 'succeeds-with-test-processor',
|
|
95
118
|
async run({ http, assert, config, accounts, firestore, waitFor, skip }) {
|