backend-manager 5.0.102 → 5.0.104

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 (38) hide show
  1. package/CLAUDE.md +59 -0
  2. package/README.md +10 -0
  3. package/TODO-MARKETING.md +53 -0
  4. package/package.json +1 -1
  5. package/src/cli/commands/auth.js +226 -0
  6. package/src/cli/commands/firebase-init.js +121 -0
  7. package/src/cli/commands/firestore.js +261 -0
  8. package/src/cli/commands/index.js +2 -0
  9. package/src/cli/index.js +16 -0
  10. package/src/manager/libraries/payment-processors/stripe.js +37 -0
  11. package/src/manager/routes/payments/cancel/post.js +66 -0
  12. package/src/manager/routes/payments/cancel/processors/stripe.js +22 -0
  13. package/src/manager/routes/payments/cancel/processors/test.js +93 -0
  14. package/src/manager/routes/payments/intent/processors/stripe.js +1 -30
  15. package/src/manager/routes/payments/portal/post.js +54 -0
  16. package/src/manager/routes/payments/portal/processors/stripe.js +62 -0
  17. package/src/manager/routes/payments/portal/processors/test.js +18 -0
  18. package/src/manager/routes/user/delete.js +2 -1
  19. package/src/manager/schemas/payments/cancel/post.js +18 -0
  20. package/src/manager/schemas/payments/portal/post.js +10 -0
  21. package/src/test/runner.js +18 -1
  22. package/src/test/test-accounts.js +92 -0
  23. package/templates/firestore.rules +1 -0
  24. package/test/events/payments/journey-payments-cancel-endpoint.js +79 -0
  25. package/test/helpers/stripe-to-unified-one-time.js +304 -0
  26. package/test/routes/payments/cancel.js +124 -0
  27. package/test/routes/payments/intent.js +7 -14
  28. package/test/routes/payments/portal.js +89 -0
  29. package/src/manager/routes/forms/delete.js +0 -37
  30. package/src/manager/routes/forms/get.js +0 -46
  31. package/src/manager/routes/forms/post.js +0 -45
  32. package/src/manager/routes/forms/public/get.js +0 -37
  33. package/src/manager/routes/forms/put.js +0 -52
  34. package/src/manager/schemas/forms/delete.js +0 -6
  35. package/src/manager/schemas/forms/get.js +0 -6
  36. package/src/manager/schemas/forms/post.js +0 -9
  37. package/src/manager/schemas/forms/public/get.js +0 -6
  38. package/src/manager/schemas/forms/put.js +0 -10
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Test: POST /payments/cancel - Validation errors
3
+ * Tests rejection cases before any processor call is made.
4
+ * See test/events/payments/journey-payments-cancel-endpoint.js for the full end-to-end journey.
5
+ */
6
+ module.exports = {
7
+ description: 'Payment cancel endpoint: validation errors',
8
+ type: 'group',
9
+ timeout: 15000,
10
+
11
+ tests: [
12
+ {
13
+ name: 'rejects-unauthenticated',
14
+ async run({ http, assert }) {
15
+ const response = await http.as('none').post('payments/cancel', {
16
+ confirmed: true,
17
+ });
18
+
19
+ assert.isError(response, 401, 'Should reject unauthenticated request');
20
+ },
21
+ },
22
+
23
+ {
24
+ name: 'rejects-missing-confirmed',
25
+ async run({ http, assert }) {
26
+ const response = await http.as('basic').post('payments/cancel', {
27
+ reason: 'Too expensive',
28
+ });
29
+
30
+ assert.isError(response, 400, 'Should reject missing confirmed field');
31
+ },
32
+ },
33
+
34
+ {
35
+ name: 'rejects-basic-user',
36
+ async run({ http, assert }) {
37
+ const response = await http.as('basic').post('payments/cancel', {
38
+ confirmed: true,
39
+ });
40
+
41
+ assert.isError(response, 400, 'Should reject basic user with no paid subscription');
42
+ },
43
+ },
44
+
45
+ {
46
+ name: 'rejects-no-processor-or-resource-id',
47
+ async run({ http, assert }) {
48
+ // cancel-no-processor starts with payment.processor=null
49
+ const response = await http.as('cancel-no-processor').post('payments/cancel', {
50
+ confirmed: true,
51
+ });
52
+
53
+ assert.isError(response, 400, 'Should reject when no processor or resourceId is set');
54
+ },
55
+ },
56
+
57
+ {
58
+ name: 'rejects-already-pending-cancellation',
59
+ async run({ http, assert }) {
60
+ // cancel-already-pending starts with cancellation.pending=true
61
+ const response = await http.as('cancel-already-pending').post('payments/cancel', {
62
+ confirmed: true,
63
+ });
64
+
65
+ assert.isError(response, 400, 'Should reject when cancellation already pending');
66
+ },
67
+ },
68
+
69
+ {
70
+ name: 'rejects-unknown-processor',
71
+ async run({ http, assert }) {
72
+ // cancel-unknown-processor starts with processor='unknown-processor'
73
+ const response = await http.as('cancel-unknown-processor').post('payments/cancel', {
74
+ confirmed: true,
75
+ });
76
+
77
+ assert.isError(response, 400, 'Should reject unknown processor');
78
+ },
79
+ },
80
+
81
+ {
82
+ name: 'succeeds-with-test-processor',
83
+ async run({ http, assert, config, accounts, firestore, waitFor }) {
84
+ const uid = accounts['route-cancel-success'].uid;
85
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
86
+
87
+ // Step 1: Create a test subscription intent to set up a proper paid subscription
88
+ const intentResponse = await http.as('route-cancel-success').post('payments/intent', {
89
+ processor: 'test',
90
+ productId: paidProduct.id,
91
+ frequency: 'monthly',
92
+ });
93
+
94
+ assert.isSuccess(intentResponse, 'Intent should succeed');
95
+
96
+ // Wait for the auto-webhook to activate the subscription
97
+ await waitFor(async () => {
98
+ const userDoc = await firestore.get(`users/${uid}`);
99
+ return userDoc?.subscription?.payment?.processor === 'test'
100
+ && userDoc?.subscription?.payment?.resourceId
101
+ && userDoc?.subscription?.status === 'active';
102
+ }, 15000, 500);
103
+
104
+ // Step 2: Call the cancel endpoint
105
+ const cancelResponse = await http.as('route-cancel-success').post('payments/cancel', {
106
+ confirmed: true,
107
+ reason: 'Too expensive',
108
+ });
109
+
110
+ assert.isSuccess(cancelResponse, 'Cancel should succeed');
111
+ assert.equal(cancelResponse.data.success, true, 'Should return success: true');
112
+
113
+ // Step 3: Verify cancellation.pending was set via the webhook pipeline
114
+ await waitFor(async () => {
115
+ const userDoc = await firestore.get(`users/${uid}`);
116
+ return userDoc?.subscription?.cancellation?.pending === true;
117
+ }, 15000, 500);
118
+
119
+ const userDoc = await firestore.get(`users/${uid}`);
120
+ assert.equal(userDoc?.subscription?.cancellation?.pending, true, 'Cancellation should be pending');
121
+ },
122
+ },
123
+ ],
124
+ };
@@ -116,9 +116,10 @@ module.exports = {
116
116
  {
117
117
  name: 'succeeds-with-test-processor',
118
118
  async run({ http, assert, config, firestore, accounts, waitFor }) {
119
+ const uid = accounts['journey-payments-intent'].uid;
119
120
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
120
121
 
121
- const response = await http.as('basic').post('payments/intent', {
122
+ const response = await http.as('journey-payments-intent').post('payments/intent', {
122
123
  processor: 'test',
123
124
  productId: paidProduct.id,
124
125
  frequency: 'monthly',
@@ -137,30 +138,26 @@ module.exports = {
137
138
  assert.equal(intentDoc.processor, 'test', 'Processor should be test');
138
139
  assert.equal(intentDoc.productId, paidProduct.id, 'Product should match');
139
140
 
140
- // Clean up: wait for auto-webhook to process, then restore basic user
141
+ // Wait for auto-webhook to process and activate the subscription
141
142
  await waitFor(async () => {
142
- const userDoc = await firestore.get(`users/${accounts.basic.uid}`);
143
+ const userDoc = await firestore.get(`users/${uid}`);
143
144
  return userDoc?.subscription?.product?.id === paidProduct.id;
144
145
  }, 15000, 500).catch(() => {});
145
-
146
- await firestore.set(`users/${accounts.basic.uid}`, {
147
- subscription: { product: { id: 'basic' }, status: 'active' },
148
- }, { merge: true });
149
146
  },
150
147
  },
151
148
 
152
149
  {
153
150
  name: 'downgrades-trial-for-user-with-history',
154
151
  async run({ http, assert, config, accounts, firestore, waitFor }) {
152
+ const uid = accounts['journey-payments-intent-trial'].uid;
155
153
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
156
- const uid = accounts.basic.uid;
157
154
  const orderDocPath = `payments-orders/_test-order-history-${uid}`;
158
155
 
159
156
  // Create fake subscription history so user is ineligible for trial
160
157
  await firestore.set(orderDocPath, { owner: uid, type: 'subscription', processor: 'test', status: 'cancelled' });
161
158
 
162
159
  try {
163
- const response = await http.as('basic').post('payments/intent', {
160
+ const response = await http.as('journey-payments-intent-trial').post('payments/intent', {
164
161
  processor: 'test',
165
162
  productId: paidProduct.id,
166
163
  frequency: 'monthly',
@@ -174,15 +171,11 @@ module.exports = {
174
171
  const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
175
172
  assert.equal(intentDoc.trial, false, 'Trial should be false (downgraded)');
176
173
 
177
- // Clean up: wait for auto-webhook, restore basic user
174
+ // Wait for auto-webhook to activate the subscription
178
175
  await waitFor(async () => {
179
176
  const userDoc = await firestore.get(`users/${uid}`);
180
177
  return userDoc?.subscription?.product?.id === paidProduct.id;
181
178
  }, 15000, 500).catch(() => {});
182
-
183
- await firestore.set(`users/${uid}`, {
184
- subscription: { product: { id: 'basic' }, status: 'active' },
185
- }, { merge: true });
186
179
  } finally {
187
180
  await firestore.delete(orderDocPath);
188
181
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Test: POST /payments/portal - Validation errors
3
+ * Tests rejection cases before any processor call is made.
4
+ */
5
+ module.exports = {
6
+ description: 'Payment portal endpoint: validation errors',
7
+ type: 'group',
8
+ timeout: 15000,
9
+
10
+ tests: [
11
+ {
12
+ name: 'rejects-unauthenticated',
13
+ async run({ http, assert }) {
14
+ const response = await http.as('none').post('payments/portal', {
15
+ returnUrl: 'https://example.com/account',
16
+ });
17
+
18
+ assert.isError(response, 401, 'Should reject unauthenticated request');
19
+ },
20
+ },
21
+
22
+ {
23
+ name: 'rejects-basic-user',
24
+ async run({ http, assert }) {
25
+ const response = await http.as('basic').post('payments/portal', {
26
+ returnUrl: 'https://example.com/account',
27
+ });
28
+
29
+ assert.isError(response, 400, 'Should reject basic user with no paid subscription');
30
+ },
31
+ },
32
+
33
+ {
34
+ name: 'rejects-no-processor',
35
+ async run({ http, assert }) {
36
+ // portal-no-processor starts with payment.processor=null
37
+ const response = await http.as('portal-no-processor').post('payments/portal', {
38
+ returnUrl: 'https://example.com/account',
39
+ });
40
+
41
+ assert.isError(response, 400, 'Should reject when no processor is set');
42
+ },
43
+ },
44
+
45
+ {
46
+ name: 'rejects-unknown-processor',
47
+ async run({ http, assert }) {
48
+ // portal-unknown-processor starts with processor='unknown-processor'
49
+ const response = await http.as('portal-unknown-processor').post('payments/portal', {
50
+ returnUrl: 'https://example.com/account',
51
+ });
52
+
53
+ assert.isError(response, 400, 'Should reject unknown processor');
54
+ },
55
+ },
56
+
57
+ {
58
+ name: 'succeeds-with-test-processor',
59
+ async run({ http, assert, config, accounts, firestore, waitFor }) {
60
+ const uid = accounts['journey-payments-portal-route'].uid;
61
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
62
+
63
+ // Set up a paid subscription with the test processor
64
+ const intentResponse = await http.as('journey-payments-portal-route').post('payments/intent', {
65
+ processor: 'test',
66
+ productId: paidProduct.id,
67
+ frequency: 'monthly',
68
+ });
69
+
70
+ assert.isSuccess(intentResponse, 'Intent should succeed');
71
+
72
+ // Wait for the auto-webhook to activate the subscription
73
+ await waitFor(async () => {
74
+ const userDoc = await firestore.get(`users/${uid}`);
75
+ return userDoc?.subscription?.payment?.processor === 'test'
76
+ && userDoc?.subscription?.status === 'active';
77
+ }, 15000, 500);
78
+
79
+ // Call the portal endpoint
80
+ const portalResponse = await http.as('journey-payments-portal-route').post('payments/portal', {
81
+ returnUrl: 'https://example.com/account',
82
+ });
83
+
84
+ assert.isSuccess(portalResponse, 'Portal should succeed');
85
+ assert.ok(portalResponse.data.url, 'Should return a URL');
86
+ },
87
+ },
88
+ ],
89
+ };
@@ -1,37 +0,0 @@
1
- /**
2
- * DELETE /forms - Delete a form
3
- * Requires authentication and ownership.
4
- */
5
- module.exports = async ({ assistant, user, settings, analytics, libraries }) => {
6
- const { admin } = libraries;
7
-
8
- // Require authentication
9
- if (!user.authenticated) {
10
- return assistant.respond('Authentication required', { code: 401 });
11
- }
12
-
13
- if (!settings.id) {
14
- return assistant.respond('Missing required parameter: id', { code: 400 });
15
- }
16
-
17
- const uid = user.auth.uid;
18
- const formRef = admin.firestore().doc(`forms/${settings.id}`);
19
- const doc = await formRef.get();
20
-
21
- if (!doc.exists) {
22
- return assistant.respond('Form not found', { code: 404 });
23
- }
24
-
25
- // Ownership check
26
- if (doc.data().owner !== uid) {
27
- return assistant.respond('Not authorized to delete this form', { code: 403 });
28
- }
29
-
30
- await formRef.delete();
31
-
32
- assistant.log(`Deleted form ${settings.id} for user ${uid}`);
33
-
34
- analytics.event('forms', { action: 'delete' });
35
-
36
- return assistant.respond({ data: { deleted: true } });
37
- };
@@ -1,46 +0,0 @@
1
- /**
2
- * GET /forms - Get a single form or list all forms for the authenticated user
3
- * Requires authentication.
4
- * - With ?id=xxx: returns a single form (with ownership check)
5
- * - Without id: returns all forms owned by the user
6
- */
7
- module.exports = async ({ assistant, user, settings, analytics, libraries }) => {
8
- const { admin } = libraries;
9
-
10
- // Require authentication
11
- if (!user.authenticated) {
12
- return assistant.respond('Authentication required', { code: 401 });
13
- }
14
-
15
- const uid = user.auth.uid;
16
-
17
- // Single form
18
- if (settings.id) {
19
- const doc = await admin.firestore().doc(`forms/${settings.id}`).get();
20
-
21
- if (!doc.exists) {
22
- return assistant.respond('Form not found', { code: 404 });
23
- }
24
-
25
- if (doc.data().owner !== uid) {
26
- return assistant.respond('Not authorized to view this form', { code: 403 });
27
- }
28
-
29
- analytics.event('forms', { action: 'get' });
30
-
31
- return assistant.respond({ data: { form: doc.data() } });
32
- }
33
-
34
- // List all forms
35
- const snapshot = await admin.firestore()
36
- .collection('forms')
37
- .where('owner', '==', uid)
38
- .orderBy('created.timestampUNIX', 'desc')
39
- .get();
40
-
41
- const forms = snapshot.docs.map(doc => doc.data());
42
-
43
- analytics.event('forms', { action: 'list' });
44
-
45
- return assistant.respond({ data: { forms } });
46
- };
@@ -1,45 +0,0 @@
1
- /**
2
- * POST /forms - Create a new form
3
- * Requires authentication. Creates a Firestore doc in the `forms` collection.
4
- */
5
- module.exports = async ({ assistant, Manager, user, settings, analytics, libraries }) => {
6
- const { admin } = libraries;
7
-
8
- // Require authentication
9
- if (!user.authenticated) {
10
- return assistant.respond('Authentication required', { code: 401 });
11
- }
12
-
13
- const uid = user.auth.uid;
14
- const now = new Date().toISOString();
15
- const nowUnix = Date.now();
16
-
17
- // Generate a new document reference
18
- const docRef = admin.firestore().collection('forms').doc();
19
-
20
- const form = {
21
- id: docRef.id,
22
- owner: uid,
23
- name: settings.name || 'Untitled Form',
24
- description: settings.description || '',
25
- settings: settings.settings || {},
26
- pages: settings.pages || [],
27
- created: {
28
- timestamp: now,
29
- timestampUNIX: nowUnix,
30
- },
31
- edited: {
32
- timestamp: now,
33
- timestampUNIX: nowUnix,
34
- },
35
- metadata: Manager.Metadata().set({ tag: 'forms/post' }),
36
- };
37
-
38
- await docRef.set(form);
39
-
40
- assistant.log(`Created form ${docRef.id} for user ${uid}`);
41
-
42
- analytics.event('forms', { action: 'create' });
43
-
44
- return assistant.respond({ data: { id: docRef.id, form } });
45
- };
@@ -1,37 +0,0 @@
1
- /**
2
- * GET /forms/public - Get a public form by ID
3
- * No authentication required. Only returns forms with settings.public = true.
4
- */
5
- module.exports = async ({ assistant, settings, analytics, libraries }) => {
6
- const { admin } = libraries;
7
-
8
- if (!settings.id) {
9
- return assistant.respond('Missing required parameter: id', { code: 400 });
10
- }
11
-
12
- const doc = await admin.firestore().doc(`forms/${settings.id}`).get();
13
-
14
- if (!doc.exists) {
15
- return assistant.respond('Form not found', { code: 404 });
16
- }
17
-
18
- const form = doc.data();
19
-
20
- // Only allow access to public forms
21
- if (!form.settings?.public) {
22
- return assistant.respond('Form not found', { code: 404 });
23
- }
24
-
25
- // Strip sensitive fields
26
- const publicForm = {
27
- id: form.id,
28
- name: form.name,
29
- description: form.description,
30
- settings: form.settings,
31
- pages: form.pages,
32
- };
33
-
34
- analytics.event('forms/public', { action: 'get' });
35
-
36
- return assistant.respond({ payload: publicForm });
37
- };
@@ -1,52 +0,0 @@
1
- /**
2
- * PUT /forms - Update an existing form
3
- * Requires authentication and ownership.
4
- */
5
- module.exports = async ({ assistant, Manager, user, settings, analytics, libraries }) => {
6
- const { admin } = libraries;
7
-
8
- // Require authentication
9
- if (!user.authenticated) {
10
- return assistant.respond('Authentication required', { code: 401 });
11
- }
12
-
13
- if (!settings.id) {
14
- return assistant.respond('Missing required parameter: id', { code: 400 });
15
- }
16
-
17
- const uid = user.auth.uid;
18
- const formRef = admin.firestore().doc(`forms/${settings.id}`);
19
- const doc = await formRef.get();
20
-
21
- if (!doc.exists) {
22
- return assistant.respond('Form not found', { code: 404 });
23
- }
24
-
25
- // Ownership check
26
- if (doc.data().owner !== uid) {
27
- return assistant.respond('Not authorized to edit this form', { code: 403 });
28
- }
29
-
30
- const now = new Date().toISOString();
31
- const nowUnix = Date.now();
32
-
33
- const updates = {
34
- name: settings.name,
35
- description: settings.description,
36
- settings: settings.settings,
37
- pages: settings.pages,
38
- edited: {
39
- timestamp: now,
40
- timestampUNIX: nowUnix,
41
- },
42
- metadata: Manager.Metadata().set({ tag: 'forms/put' }),
43
- };
44
-
45
- await formRef.update(updates);
46
-
47
- assistant.log(`Updated form ${settings.id} for user ${uid}`);
48
-
49
- analytics.event('forms', { action: 'update' });
50
-
51
- return assistant.respond({ data: { id: settings.id } });
52
- };
@@ -1,6 +0,0 @@
1
- /**
2
- * Schema for DELETE /forms
3
- */
4
- module.exports = () => ({
5
- id: { types: ['string'], default: undefined, required: true },
6
- });
@@ -1,6 +0,0 @@
1
- /**
2
- * Schema for GET /forms
3
- */
4
- module.exports = () => ({
5
- id: { types: ['string'], default: undefined },
6
- });
@@ -1,9 +0,0 @@
1
- /**
2
- * Schema for POST /forms (create)
3
- */
4
- module.exports = () => ({
5
- name: { types: ['string'], default: 'Untitled Form' },
6
- description: { types: ['string'], default: '' },
7
- settings: { types: ['object'], default: {} },
8
- pages: { types: ['array'], default: [] },
9
- });
@@ -1,6 +0,0 @@
1
- /**
2
- * Schema for GET /forms/public
3
- */
4
- module.exports = () => ({
5
- id: { types: ['string'], default: undefined, required: true },
6
- });
@@ -1,10 +0,0 @@
1
- /**
2
- * Schema for PUT /forms (update)
3
- */
4
- module.exports = () => ({
5
- id: { types: ['string'], default: undefined, required: true },
6
- name: { types: ['string'], default: undefined },
7
- description: { types: ['string'], default: undefined },
8
- settings: { types: ['object'], default: undefined },
9
- pages: { types: ['array'], default: undefined },
10
- });