backend-manager 5.0.89 → 5.0.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/CLAUDE.md +147 -8
  3. package/README.md +6 -6
  4. package/TODO-MARKETING.md +3 -0
  5. package/TODO-PAYMENT-v2.md +71 -0
  6. package/TODO.md +7 -0
  7. package/package.json +7 -5
  8. package/src/cli/commands/{emulators.js → emulator.js} +15 -15
  9. package/src/cli/commands/index.js +1 -1
  10. package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
  11. package/src/cli/commands/setup-tests/index.js +2 -2
  12. package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
  13. package/src/cli/commands/test.js +16 -16
  14. package/src/cli/index.js +15 -4
  15. package/src/manager/events/auth/on-create.js +5 -158
  16. package/src/manager/events/firestore/payments-webhooks/analytics.js +171 -0
  17. package/src/manager/events/firestore/payments-webhooks/on-write.js +95 -297
  18. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +19 -10
  19. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  20. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +61 -0
  21. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +22 -9
  22. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +21 -8
  23. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +18 -8
  24. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +18 -7
  25. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +26 -8
  26. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +24 -9
  27. package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
  28. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
  29. package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
  30. package/src/manager/helpers/user.js +1 -0
  31. package/src/manager/index.js +12 -0
  32. package/src/manager/libraries/email.js +483 -0
  33. package/src/manager/libraries/infer-contact.js +140 -0
  34. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  35. package/src/manager/libraries/payment-processors/stripe.js +87 -48
  36. package/src/manager/libraries/payment-processors/test.js +4 -4
  37. package/src/manager/libraries/prompts/infer-contact.md +43 -0
  38. package/src/manager/routes/admin/backup/post.js +4 -3
  39. package/src/manager/routes/admin/email/post.js +11 -428
  40. package/src/manager/routes/admin/hook/post.js +3 -2
  41. package/src/manager/routes/admin/notification/post.js +14 -12
  42. package/src/manager/routes/admin/post/post.js +5 -6
  43. package/src/manager/routes/admin/post/put.js +3 -2
  44. package/src/manager/routes/admin/stats/get.js +19 -10
  45. package/src/manager/routes/general/email/post.js +8 -21
  46. package/src/manager/routes/marketing/contact/post.js +2 -100
  47. package/src/manager/routes/payments/intent/post.js +44 -2
  48. package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
  49. package/src/manager/routes/payments/intent/processors/test.js +20 -25
  50. package/src/manager/routes/user/oauth2/_helpers.js +3 -2
  51. package/src/manager/routes/user/oauth2/delete.js +3 -3
  52. package/src/manager/routes/user/oauth2/get.js +2 -2
  53. package/src/manager/routes/user/oauth2/post.js +9 -9
  54. package/src/manager/routes/user/sessions/delete.js +4 -3
  55. package/src/manager/routes/user/signup/post.js +254 -54
  56. package/src/manager/schemas/admin/email/post.js +10 -5
  57. package/src/test/run-tests.js +1 -1
  58. package/src/test/runner.js +11 -0
  59. package/src/test/test-accounts.js +18 -0
  60. package/templates/backend-manager-config.json +31 -12
  61. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  62. package/test/events/payments/journey-payments-one-time.js +128 -0
  63. package/test/events/payments/journey-payments-plan-change.js +126 -0
  64. package/test/events/payments/journey-payments-upgrade.js +2 -2
  65. package/test/functions/admin/send-email.js +1 -88
  66. package/test/helpers/email.js +381 -0
  67. package/test/helpers/infer-contact.js +299 -0
  68. package/test/routes/admin/email.js +41 -90
  69. package/REFACTOR-BEM-API.md +0 -76
  70. package/REFACTOR-MIDDLEWARE.md +0 -62
  71. package/REFACTOR-PAYMENT.md +0 -66
  72. /package/bin/{bem → backend-manager} +0 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Test: Payment Journey - One-Time Purchase
3
+ * Simulates: user → test intent (one-time product) → auto-webhook → purchase-completed
4
+ *
5
+ * Uses the test processor to exercise the full intent→webhook→trigger pipeline
6
+ * for one-time payments. Unlike subscriptions, one-time payments only write to
7
+ * payments-orders/{orderId} — they do NOT modify users/{uid}.subscription.
8
+ *
9
+ * Requires at least one product with type: 'one-time' in config.payment.products
10
+ */
11
+ module.exports = {
12
+ description: 'Payment journey: one-time purchase via test intent → purchase-completed',
13
+ type: 'suite',
14
+ timeout: 30000,
15
+
16
+ tests: [
17
+ {
18
+ name: 'resolve-one-time-product',
19
+ async run({ accounts, firestore, assert, state, config }) {
20
+ const uid = accounts['journey-payments-one-time'].uid;
21
+ const userDoc = await firestore.get(`users/${uid}`);
22
+
23
+ assert.ok(userDoc, 'User doc should exist');
24
+
25
+ // Resolve first one-time product from config
26
+ const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
27
+ assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
28
+
29
+ state.uid = uid;
30
+ state.productId = oneTimeProduct.id;
31
+ state.productName = oneTimeProduct.name;
32
+ state.price = oneTimeProduct.prices.once.amount;
33
+
34
+ // Snapshot subscription before purchase — should remain unchanged after
35
+ state.subscriptionBefore = userDoc.subscription || null;
36
+ },
37
+ },
38
+
39
+ {
40
+ name: 'create-one-time-intent',
41
+ async run({ http, assert, state }) {
42
+ const response = await http.as('journey-payments-one-time').post('payments/intent', {
43
+ processor: 'test',
44
+ productId: state.productId,
45
+ });
46
+
47
+ assert.isSuccess(response, 'Intent should succeed');
48
+ assert.ok(response.data.id, 'Should return intent ID');
49
+ assert.ok(response.data.orderId, 'Should return orderId');
50
+ assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
51
+ assert.ok(response.data.url, 'Should return URL');
52
+
53
+ state.intentId = response.data.id;
54
+ state.orderId = response.data.orderId;
55
+
56
+ // Derive webhook event ID from intent ID (same timestamp)
57
+ state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
58
+ },
59
+ },
60
+
61
+ {
62
+ name: 'webhook-transition-purchase-completed',
63
+ async run({ firestore, assert, state, waitFor }) {
64
+ // Poll until the webhook is processed
65
+ await waitFor(async () => {
66
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
67
+ return doc?.status === 'completed';
68
+ }, 15000, 500);
69
+
70
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
71
+ assert.ok(webhookDoc, 'Webhook doc should exist');
72
+ assert.equal(webhookDoc.transition, 'purchase-completed', 'Transition should be purchase-completed');
73
+ assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
74
+ assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
75
+ },
76
+ },
77
+
78
+ {
79
+ name: 'order-doc-created',
80
+ async run({ firestore, assert, state }) {
81
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
82
+
83
+ assert.ok(orderDoc, 'Order doc should exist');
84
+ assert.equal(orderDoc.id, state.orderId, 'ID should match orderId');
85
+ assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
86
+ assert.equal(orderDoc.owner, state.uid, 'Owner should match');
87
+ assert.equal(orderDoc.processor, 'test', 'Processor should be test');
88
+ assert.equal(orderDoc.unified.product.id, state.productId, `Product should be ${state.productId}`);
89
+ assert.equal(orderDoc.unified.payment.processor, 'test', 'Unified processor should be test');
90
+ assert.equal(orderDoc.unified.payment.orderId, state.orderId, 'Unified orderId should match');
91
+ },
92
+ },
93
+
94
+ {
95
+ name: 'subscription-unchanged',
96
+ async run({ firestore, assert, state }) {
97
+ // One-time payments must NOT modify users/{uid}.subscription
98
+ const userDoc = await firestore.get(`users/${state.uid}`);
99
+ const subAfter = userDoc.subscription || null;
100
+
101
+ assert.deepEqual(
102
+ subAfter?.product?.id,
103
+ state.subscriptionBefore?.product?.id,
104
+ 'Subscription product should be unchanged after one-time purchase',
105
+ );
106
+ assert.deepEqual(
107
+ subAfter?.status,
108
+ state.subscriptionBefore?.status,
109
+ 'Subscription status should be unchanged after one-time purchase',
110
+ );
111
+ },
112
+ },
113
+
114
+ {
115
+ name: 'intent-doc-created',
116
+ async run({ firestore, assert, state }) {
117
+ const intentDoc = await firestore.get(`payments-intents/${state.orderId}`);
118
+
119
+ assert.ok(intentDoc, 'Intent doc should exist');
120
+ assert.equal(intentDoc.id, state.orderId, 'ID should match orderId');
121
+ assert.equal(intentDoc.intentId, state.intentId, 'Intent ID should match processor session ID');
122
+ assert.equal(intentDoc.owner, state.uid, 'Owner should match');
123
+ assert.equal(intentDoc.processor, 'test', 'Processor should be test');
124
+ assert.equal(intentDoc.productId, state.productId, `Product should be ${state.productId}`);
125
+ },
126
+ },
127
+ ],
128
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Test: Payment Journey - Plan Change
3
+ * Simulates: basic → paid product A → plan-changed webhook → paid product B
4
+ *
5
+ * Uses test intent for initial subscription, then manual webhook to change plans.
6
+ * Requires at least two paid subscription products in config.
7
+ */
8
+ module.exports = {
9
+ description: 'Payment journey: paid product A → plan-changed → paid product B',
10
+ type: 'suite',
11
+ timeout: 30000,
12
+
13
+ tests: [
14
+ {
15
+ name: 'setup-paid-subscription',
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor }) {
17
+ const uid = accounts['journey-payments-plan-change'].uid;
18
+
19
+ // Resolve two distinct paid subscription products from config
20
+ const paidProducts = config.payment.products.filter(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
21
+ assert.ok(paidProducts.length >= 2, 'Config should have at least two paid subscription products');
22
+
23
+ const productA = paidProducts[0];
24
+ const productB = paidProducts[1];
25
+
26
+ state.uid = uid;
27
+ state.productA = { id: productA.id, name: productA.name, priceId: productA.prices.monthly.stripe };
28
+ state.productB = { id: productB.id, name: productB.name, priceId: productB.prices.monthly.stripe };
29
+
30
+ // Create subscription via test intent (product A)
31
+ const response = await http.as('journey-payments-plan-change').post('payments/intent', {
32
+ processor: 'test',
33
+ productId: productA.id,
34
+ frequency: 'monthly',
35
+ });
36
+ assert.isSuccess(response, 'Intent should succeed');
37
+ state.orderId = response.data.orderId;
38
+
39
+ // Wait for subscription to activate
40
+ await waitFor(async () => {
41
+ const userDoc = await firestore.get(`users/${uid}`);
42
+ return userDoc?.subscription?.product?.id === productA.id;
43
+ }, 15000, 500);
44
+
45
+ const userDoc = await firestore.get(`users/${uid}`);
46
+ assert.equal(userDoc.subscription?.product?.id, productA.id, `Should start as ${productA.id}`);
47
+ assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
48
+
49
+ state.subscriptionId = userDoc.subscription.payment.resourceId;
50
+ },
51
+ },
52
+
53
+ {
54
+ name: 'send-plan-change-webhook',
55
+ async run({ http, assert, state, config }) {
56
+ const futureDate = new Date();
57
+ futureDate.setMonth(futureDate.getMonth() + 1);
58
+
59
+ state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
60
+
61
+ // Send subscription.updated with a different product's price ID
62
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
63
+ id: state.eventId,
64
+ type: 'customer.subscription.updated',
65
+ data: {
66
+ object: {
67
+ id: state.subscriptionId,
68
+ object: 'subscription',
69
+ status: 'active',
70
+ metadata: { uid: state.uid, orderId: state.orderId },
71
+ cancel_at_period_end: false,
72
+ canceled_at: null,
73
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
74
+ current_period_start: Math.floor(Date.now() / 1000),
75
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
76
+ trial_start: null,
77
+ trial_end: null,
78
+ plan: { id: state.productB.priceId, interval: 'month' },
79
+ },
80
+ },
81
+ });
82
+
83
+ assert.isSuccess(response, 'Webhook should be accepted');
84
+ },
85
+ },
86
+
87
+ {
88
+ name: 'plan-changed-transition-detected',
89
+ async run({ firestore, assert, state, waitFor }) {
90
+ await waitFor(async () => {
91
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
92
+ return doc?.status === 'completed';
93
+ }, 15000, 500);
94
+
95
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
96
+ assert.ok(webhookDoc, 'Webhook doc should exist');
97
+ assert.equal(webhookDoc.transition, 'plan-changed', 'Transition should be plan-changed');
98
+ },
99
+ },
100
+
101
+ {
102
+ name: 'subscription-updated-to-product-b',
103
+ async run({ firestore, assert, state }) {
104
+ const userDoc = await firestore.get(`users/${state.uid}`);
105
+
106
+ assert.equal(userDoc.subscription.product.id, state.productB.id, `Product should be ${state.productB.id}`);
107
+ assert.equal(userDoc.subscription.product.name, state.productB.name, `Product name should be ${state.productB.name}`);
108
+ assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
109
+ assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
110
+ assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
111
+ assert.equal(userDoc.subscription.payment.resourceId, state.subscriptionId, 'Resource ID should be the same subscription');
112
+ },
113
+ },
114
+
115
+ {
116
+ name: 'order-doc-updated',
117
+ async run({ firestore, assert, state }) {
118
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
119
+
120
+ assert.ok(orderDoc, 'Order doc should exist');
121
+ assert.equal(orderDoc.unified.product.id, state.productB.id, `Order product should be ${state.productB.id}`);
122
+ assert.equal(orderDoc.unified.status, 'active', 'Order status should be active');
123
+ },
124
+ },
125
+ ],
126
+ };
@@ -88,8 +88,8 @@ module.exports = {
88
88
  assert.equal(orderDoc.owner, state.uid, 'Owner should match');
89
89
  assert.equal(orderDoc.processor, 'test', 'Processor should be test');
90
90
  assert.equal(orderDoc.resourceId, state.subscriptionId, 'Resource ID should match');
91
- assert.equal(orderDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
92
- assert.equal(orderDoc.subscription.status, 'active', 'Status should be active');
91
+ assert.equal(orderDoc.unified.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
92
+ assert.equal(orderDoc.unified.status, 'active', 'Status should be active');
93
93
  },
94
94
  },
95
95
 
@@ -8,7 +8,6 @@
8
8
  *
9
9
  * Possible status values:
10
10
  * - 'sent': Email sent via SendGrid
11
- * - 'non-unique': Duplicate email detected (when ensureUnique: true)
12
11
  * - 'queued': Scheduled beyond 71 hours, saved to queue for later
13
12
  */
14
13
  module.exports = {
@@ -26,7 +25,6 @@ module.exports = {
26
25
  const response = await http.command('admin:send-email', {
27
26
  to: [{ email: `_test-receiver@${config.domain}` }],
28
27
  copy: false,
29
- ensureUnique: false,
30
28
  });
31
29
 
32
30
  assert.isError(response, 400, 'Missing subject should return 400');
@@ -44,7 +42,6 @@ module.exports = {
44
42
  subject: 'BEM Test Email - Status Sent',
45
43
  to: [{ email: `_test-receiver@${config.domain}`, name: 'Test Receiver' }],
46
44
  copy: false,
47
- ensureUnique: false,
48
45
  data: {
49
46
  email: {
50
47
  subject: 'BEM Test Email - Status Sent',
@@ -55,7 +52,7 @@ module.exports = {
55
52
 
56
53
  assert.isSuccess(response, 'Admin should be able to send email');
57
54
  assert.hasProperty(response, 'data.status', 'Response should have status');
58
- assert.equal(response.data.status, 'sent', 'Status should be sent when ensureUnique is false');
55
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
59
56
  },
60
57
  },
61
58
 
@@ -73,7 +70,6 @@ module.exports = {
73
70
  subject: 'BEM Test Email - Status Queued',
74
71
  to: [{ email: `_test-receiver@${config.domain}`, name: 'Test Receiver' }],
75
72
  copy: false,
76
- ensureUnique: false,
77
73
  sendAt: sendAt,
78
74
  data: {
79
75
  email: {
@@ -89,89 +85,6 @@ module.exports = {
89
85
  },
90
86
  },
91
87
 
92
- // Test 6: Status 'non-unique' - Duplicate email detected
93
- // This test sends two identical emails with ensureUnique: true
94
- // The second one should return 'non-unique' status
95
- {
96
- name: 'status-non-unique',
97
- auth: 'admin',
98
- timeout: 120000, // Long timeout because ensureUnique waits ~45 seconds
99
-
100
- async run({ http, assert, config }) {
101
- const uniqueSubject = `BEM Test Email - Unique Check ${Date.now()}`;
102
-
103
- // Send first email with ensureUnique: true
104
- const response1Promise = http.command('admin:send-email', {
105
- subject: uniqueSubject,
106
- to: [{ email: `_test-receiver@${config.domain}` }],
107
- copy: false,
108
- ensureUnique: true,
109
- categories: ['bem-test-unique'],
110
- data: {
111
- email: {
112
- subject: uniqueSubject,
113
- body: 'Testing ensureUnique feature.',
114
- },
115
- },
116
- });
117
-
118
- // Send second identical email immediately (same subject, to, categories = same hash)
119
- const response2Promise = http.command('admin:send-email', {
120
- subject: uniqueSubject,
121
- to: [{ email: `_test-receiver@${config.domain}` }],
122
- copy: false,
123
- ensureUnique: true,
124
- categories: ['bem-test-unique'],
125
- data: {
126
- email: {
127
- subject: uniqueSubject,
128
- body: 'Testing ensureUnique feature.',
129
- },
130
- },
131
- });
132
-
133
- // Wait for both
134
- const [response1, response2] = await Promise.all([response1Promise, response2Promise]);
135
-
136
- // Both should succeed
137
- assert.isSuccess(response1, 'First email should succeed');
138
- assert.isSuccess(response2, 'Second email should succeed');
139
-
140
- // One should be 'sent', the other 'non-unique'
141
- const statuses = [response1.data.status, response2.data.status].sort();
142
- assert.equal(statuses[0], 'non-unique', 'One email should have status non-unique');
143
- assert.equal(statuses[1], 'sent', 'One email should have status sent');
144
- },
145
- },
146
-
147
- // Test 7: Unauthorized sender domain rejected by SendGrid
148
- // TODO: SendGrid accepts emails from unauthorized domains at API level
149
- // (they may fail at delivery). Consider adding BEM-level validation.
150
- // {
151
- // name: 'unauthorized-from-domain-rejected',
152
- // auth: 'admin',
153
- // timeout: 30000,
154
- //
155
- // async run({ http, assert, config }) {
156
- // const response = await http.command('admin:send-email', {
157
- // subject: 'BEM Test Email - Unauthorized Sender',
158
- // to: [{ email: `_test-receiver@${config.domain}` }],
159
- // from: { email: 'fake-sender@example.com', name: 'Fake Sender' },
160
- // copy: false,
161
- // ensureUnique: false,
162
- // data: {
163
- // email: {
164
- // subject: 'BEM Test Email - Unauthorized Sender',
165
- // body: 'This email should fail because the sender domain is not authorized.',
166
- // },
167
- // },
168
- // });
169
- //
170
- // // SendGrid rejects emails from unauthorized sender domains with 403
171
- // assert.isError(response, 500, 'Sending from unauthorized domain should fail');
172
- // },
173
- // },
174
-
175
88
  // --- Auth rejection tests (at end per convention) ---
176
89
  {
177
90
  name: 'unauthenticated-rejected',