backend-manager 5.1.4 → 5.2.0

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 (75) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +23 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +15 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/testing.md +36 -0
  8. package/package.json +1 -1
  9. package/src/cli/commands/emulator.js +44 -8
  10. package/src/cli/commands/serve.js +73 -7
  11. package/src/cli/commands/test.js +47 -1
  12. package/src/cli/commands/watch.js +15 -3
  13. package/src/manager/helpers/user.js +29 -0
  14. package/src/manager/index.js +29 -0
  15. package/src/manager/libraries/email/data/disposable-domains.json +8 -0
  16. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  17. package/src/manager/libraries/email/providers/beehiiv.js +1 -0
  18. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  19. package/src/manager/libraries/payment/processors/test.js +8 -1
  20. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  21. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  22. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  23. package/src/manager/routes/marketing/webhook/post.js +180 -0
  24. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  25. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  26. package/src/manager/routes/payments/cancel/post.js +2 -2
  27. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  28. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  29. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  30. package/src/manager/routes/user/signup/post.js +65 -1
  31. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  32. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  33. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  34. package/src/manager/schemas/payments/cancel/post.js +5 -0
  35. package/src/manager/schemas/user/signup/post.js +5 -0
  36. package/src/test/runner.js +61 -18
  37. package/src/test/test-accounts.js +94 -12
  38. package/src/test/utils/http-client.js +4 -3
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  40. package/test/events/payments/journey-payments-cancel.js +4 -5
  41. package/test/events/payments/journey-payments-failure.js +0 -1
  42. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  43. package/test/events/payments/journey-payments-one-time.js +6 -3
  44. package/test/events/payments/journey-payments-plan-change.js +5 -5
  45. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  46. package/test/events/payments/journey-payments-suspend.js +4 -5
  47. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  48. package/test/events/payments/journey-payments-trial.js +2 -3
  49. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  50. package/test/functions/admin/database-read.js +0 -14
  51. package/test/functions/admin/database-write.js +0 -14
  52. package/test/functions/admin/firestore-query.js +0 -14
  53. package/test/functions/admin/firestore-read.js +0 -15
  54. package/test/functions/admin/firestore-write.js +0 -11
  55. package/test/functions/general/add-marketing-contact.js +16 -14
  56. package/test/helpers/email.js +1 -1
  57. package/test/helpers/infer-contact.js +3 -3
  58. package/test/helpers/user.js +241 -2
  59. package/test/helpers/webhook-forward.js +392 -0
  60. package/test/marketing/newsletter-generate.js +17 -7
  61. package/test/routes/admin/database.js +0 -13
  62. package/test/routes/admin/firestore-query.js +0 -13
  63. package/test/routes/admin/firestore.js +0 -14
  64. package/test/routes/admin/infer-contact.js +6 -3
  65. package/test/routes/admin/post.js +4 -2
  66. package/test/routes/marketing/contact.js +60 -26
  67. package/test/routes/marketing/email-preferences.js +145 -69
  68. package/test/routes/marketing/webhook-forward.js +54 -0
  69. package/test/routes/marketing/webhook.js +582 -0
  70. package/test/routes/payments/cancel.js +2 -7
  71. package/test/routes/payments/dispute-alert.js +0 -39
  72. package/test/routes/payments/refund.js +3 -1
  73. package/test/routes/payments/webhook.js +5 -26
  74. package/test/routes/test/usage.js +2 -2
  75. package/test/routes/user/signup.js +114 -0
@@ -105,9 +105,6 @@ module.exports = {
105
105
  async run({ http, assert, firestore }) {
106
106
  const alertId = '_test-dispute-valid';
107
107
 
108
- // Clean up any existing doc
109
- await firestore.delete(`payments-disputes/${alertId}`);
110
-
111
108
  const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
112
109
  id: alertId,
113
110
  card: '4242424242424242',
@@ -157,9 +154,6 @@ module.exports = {
157
154
  // Verify raw payload is preserved
158
155
  assert.ok(doc.raw, 'Raw payload should be preserved');
159
156
  assert.equal(doc.raw.id, alertId, 'Raw id should match');
160
-
161
- // Clean up
162
- await firestore.delete(`payments-disputes/${alertId}`);
163
157
  },
164
158
  },
165
159
 
@@ -169,9 +163,6 @@ module.exports = {
169
163
  async run({ http, assert, firestore }) {
170
164
  const alertId = '_test-dispute-alertid-field';
171
165
 
172
- // Clean up any existing doc
173
- await firestore.delete(`payments-disputes/${alertId}`);
174
-
175
166
  // Chargeblast alert.created events use alertId instead of id
176
167
  const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
177
168
  alertId: alertId,
@@ -188,9 +179,6 @@ module.exports = {
188
179
  assert.equal(doc.id, alertId, 'Doc ID should match alertId');
189
180
  assert.equal(doc.alert.id, alertId, 'Alert id should be set from alertId');
190
181
  assert.equal(doc.alert.card.last4, '5805', 'Should extract last4 from masked card');
191
-
192
- // Clean up
193
- await firestore.delete(`payments-disputes/${alertId}`);
194
182
  },
195
183
  },
196
184
 
@@ -200,9 +188,6 @@ module.exports = {
200
188
  async run({ http, assert, firestore }) {
201
189
  const alertId = '_test-dispute-minimal';
202
190
 
203
- // Clean up any existing doc
204
- await firestore.delete(`payments-disputes/${alertId}`);
205
-
206
191
  // Send minimal alert (alert.created shape — no externalOrder, metadata, etc.)
207
192
  const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
208
193
  id: alertId,
@@ -224,9 +209,6 @@ module.exports = {
224
209
  assert.equal(doc.alert.reasonCode, null, 'Reason code should be null when not provided');
225
210
  assert.equal(doc.alert.subprovider, null, 'Subprovider should be null when not provided');
226
211
  assert.equal(doc.alert.isRefunded, false, 'isRefunded should default to false');
227
-
228
- // Clean up
229
- await firestore.delete(`payments-disputes/${alertId}`);
230
212
  },
231
213
  },
232
214
 
@@ -236,9 +218,6 @@ module.exports = {
236
218
  async run({ http, assert, firestore }) {
237
219
  const alertId = '_test-dispute-last4';
238
220
 
239
- // Clean up any existing doc
240
- await firestore.delete(`payments-disputes/${alertId}`);
241
-
242
221
  const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
243
222
  id: alertId,
244
223
  card: '1234',
@@ -251,9 +230,6 @@ module.exports = {
251
230
  const doc = await firestore.get(`payments-disputes/${alertId}`);
252
231
  assert.equal(doc.alert.card.last4, '1234', 'Should use card value as last4 when already 4 digits');
253
232
  assert.equal(doc.alert.processor, 'stripe', 'Processor should default to stripe');
254
-
255
- // Clean up
256
- await firestore.delete(`payments-disputes/${alertId}`);
257
233
  },
258
234
  },
259
235
 
@@ -263,9 +239,6 @@ module.exports = {
263
239
  async run({ http, assert, firestore }) {
264
240
  const alertId = '_test-dispute-duplicate';
265
241
 
266
- // Clean up any existing doc
267
- await firestore.delete(`payments-disputes/${alertId}`);
268
-
269
242
  // Send first alert
270
243
  await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
271
244
  id: alertId,
@@ -284,9 +257,6 @@ module.exports = {
284
257
 
285
258
  assert.isSuccess(response, 'Duplicate should still return 200');
286
259
  assert.equal(response.data.duplicate, true, 'Should indicate duplicate');
287
-
288
- // Clean up
289
- await firestore.delete(`payments-disputes/${alertId}`);
290
260
  },
291
261
  },
292
262
 
@@ -320,9 +290,6 @@ module.exports = {
320
290
  doc.status === 'pending' || doc.status === 'processing',
321
291
  'Status should be pending or processing after retry',
322
292
  );
323
-
324
- // Clean up
325
- await firestore.delete(`payments-disputes/${alertId}`);
326
293
  },
327
294
  },
328
295
 
@@ -332,9 +299,6 @@ module.exports = {
332
299
  async run({ http, assert, firestore }) {
333
300
  const alertId = '_test-dispute-default-provider';
334
301
 
335
- // Clean up any existing doc
336
- await firestore.delete(`payments-disputes/${alertId}`);
337
-
338
302
  // Send without provider query param
339
303
  const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
340
304
  id: alertId,
@@ -347,9 +311,6 @@ module.exports = {
347
311
 
348
312
  const doc = await firestore.get(`payments-disputes/${alertId}`);
349
313
  assert.equal(doc.provider, 'chargeblast', 'Provider should default to chargeblast');
350
-
351
- // Clean up
352
- await firestore.delete(`payments-disputes/${alertId}`);
353
314
  },
354
315
  },
355
316
  ],
@@ -133,10 +133,12 @@ module.exports = {
133
133
  && userDoc?.subscription?.status === 'active';
134
134
  }, 15000, 500);
135
135
 
136
- // Step 2: Cancel the subscription first (refund requires cancellation)
136
+ // Step 2: Cancel the subscription first (refund requires cancellation).
137
+ // skipGuards bypasses the 24-hour subscription-age guard on the cancel route.
137
138
  const cancelResponse = await http.as('route-refund-success').post('payments/cancel', {
138
139
  confirmed: true,
139
140
  reason: 'Too expensive',
141
+ skipGuards: true,
140
142
  });
141
143
 
142
144
  assert.isSuccess(cancelResponse, 'Cancel should succeed');
@@ -50,9 +50,6 @@ module.exports = {
50
50
  async run({ http, assert, firestore }) {
51
51
  const eventId = '_test-evt-valid-webhook';
52
52
 
53
- // Clean up any existing doc
54
- await firestore.delete(`payments-webhooks/${eventId}`);
55
-
56
53
  const response = await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
57
54
  id: eventId,
58
55
  type: 'customer.subscription.updated',
@@ -76,9 +73,6 @@ module.exports = {
76
73
  doc.status === 'pending' || doc.status === 'processing' || doc.status === 'completed' || doc.status === 'failed',
77
74
  'Status should be pending, processing, completed, or failed',
78
75
  );
79
-
80
- // Clean up
81
- await firestore.delete(`payments-webhooks/${eventId}`);
82
76
  },
83
77
  },
84
78
 
@@ -88,11 +82,9 @@ module.exports = {
88
82
  async run({ http, assert, firestore }) {
89
83
  const eventId = '_test-evt-duplicate';
90
84
 
91
- // Clean up any existing doc
92
- await firestore.delete(`payments-webhooks/${eventId}`);
93
-
94
- // Send first webhook
95
- await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
85
+ // Use the test processor so the on-write trigger doesn't require STRIPE_SECRET_KEY
86
+ // (a failed first webhook would let the dedup-retry branch fire instead of returning duplicate=true)
87
+ const send = () => http.as('none').post(`payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`, {
96
88
  id: eventId,
97
89
  type: 'customer.subscription.updated',
98
90
  data: {
@@ -104,24 +96,11 @@ module.exports = {
104
96
  },
105
97
  });
106
98
 
107
- // Send duplicate
108
- const response = await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
109
- id: eventId,
110
- type: 'customer.subscription.updated',
111
- data: {
112
- object: {
113
- id: 'sub_test_dup',
114
- metadata: { uid: TEST_ACCOUNTS.basic.uid },
115
- status: 'active',
116
- },
117
- },
118
- });
99
+ await send();
100
+ const response = await send();
119
101
 
120
102
  assert.isSuccess(response, 'Duplicate should still return 200');
121
103
  assert.equal(response.data.duplicate, true, 'Should indicate duplicate');
122
-
123
- // Clean up
124
- await firestore.delete(`payments-webhooks/${eventId}`);
125
104
  },
126
105
  },
127
106
  ],
@@ -258,12 +258,12 @@ module.exports = {
258
258
  const doc = await firestore.get(`users/${accounts.basic.uid}`);
259
259
  return doc?.usage?.requests?.daily === 0;
260
260
  },
261
- 10000,
261
+ 15000,
262
262
  500
263
263
  );
264
264
  assert.ok(true, 'Daily counter was reset to 0 by cron');
265
265
  } catch (error) {
266
- assert.fail('Daily counter should be reset to 0 within 10s');
266
+ assert.fail('Daily counter should be reset to 0 within 15s');
267
267
  }
268
268
  },
269
269
  },
@@ -234,6 +234,120 @@ module.exports = {
234
234
  },
235
235
  },
236
236
 
237
+ // --- Consent capture tests ---
238
+ {
239
+ name: 'consent-granted-both-records-canonical-shape',
240
+ async run({ http, firestore, assert, accounts }) {
241
+ const consentText = {
242
+ legal: 'I agree to the Terms of Service and Privacy Policy.',
243
+ marketing: 'Send me product updates and newsletters. You can unsubscribe anytime.',
244
+ };
245
+
246
+ // Use absurdly-old client timestamp to prove server time wins (defense vs clock skew)
247
+ const beforeMs = Date.now();
248
+
249
+ const signupResponse = await http.as('consent-granted').post('user/signup', {
250
+ consent: {
251
+ legal: { granted: true, text: consentText.legal, timestamp: '2000-01-01T00:00:00.000Z' },
252
+ marketing: { granted: true, text: consentText.marketing, timestamp: '2000-01-01T00:00:00.000Z' },
253
+ },
254
+ });
255
+
256
+ assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
257
+
258
+ const afterMs = Date.now();
259
+ const userDoc = await firestore.get(`users/${accounts['consent-granted'].uid}`);
260
+
261
+ // Legal
262
+ assert.equal(userDoc?.consent?.legal?.status, 'granted', 'consent.legal.status should be granted');
263
+ assert.equal(userDoc?.consent?.legal?.grantedAt?.source, 'signup', 'legal grantedAt.source should be signup-form');
264
+ assert.equal(userDoc?.consent?.legal?.grantedAt?.text, consentText.legal, 'legal grantedAt.text should match client payload');
265
+ assert.ok(userDoc?.consent?.legal?.grantedAt?.timestamp, 'legal grantedAt.timestamp should be set');
266
+ assert.equal(typeof userDoc?.consent?.legal?.grantedAt?.timestampUNIX, 'number', 'legal grantedAt.timestampUNIX should be number');
267
+
268
+ // Server time MUST be used (the client-supplied 2000-01-01 should NOT appear)
269
+ const legalUNIX = userDoc.consent.legal.grantedAt.timestampUNIX;
270
+ const beforeUNIX = Math.floor(beforeMs / 1000);
271
+ const afterUNIX = Math.floor(afterMs / 1000);
272
+ assert.ok(
273
+ legalUNIX >= beforeUNIX && legalUNIX <= afterUNIX,
274
+ `legal grantedAt.timestampUNIX (${legalUNIX}) must be server time, not client time. expected between ${beforeUNIX} and ${afterUNIX}`
275
+ );
276
+
277
+ // Marketing
278
+ assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'consent.marketing.status should be granted');
279
+ assert.equal(userDoc?.consent?.marketing?.grantedAt?.source, 'signup', 'marketing grantedAt.source should be signup-form');
280
+ assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, consentText.marketing, 'marketing grantedAt.text should match client payload');
281
+ assert.equal(typeof userDoc?.consent?.marketing?.grantedAt?.timestampUNIX, 'number', 'marketing grantedAt.timestampUNIX should be number');
282
+
283
+ // revokedAt should be all-null sibling object (NOT undefined, NOT missing)
284
+ assert.ok(userDoc?.consent?.marketing?.revokedAt, 'marketing.revokedAt object should exist (not null)');
285
+ assert.equal(userDoc?.consent?.marketing?.revokedAt?.timestamp, null, 'marketing revokedAt.timestamp should be null');
286
+ assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, null, 'marketing revokedAt.source should be null');
287
+ },
288
+ },
289
+
290
+ {
291
+ name: 'consent-marketing-declined-records-revokedAt',
292
+ async run({ http, firestore, assert, accounts }) {
293
+ const legalText = 'I agree to the Terms of Service and Privacy Policy.';
294
+
295
+ const signupResponse = await http.as('consent-declined').post('user/signup', {
296
+ consent: {
297
+ legal: { granted: true, text: legalText },
298
+ marketing: { granted: false, text: 'Send me updates.' },
299
+ },
300
+ });
301
+
302
+ assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
303
+
304
+ const userDoc = await firestore.get(`users/${accounts['consent-declined'].uid}`);
305
+
306
+ // Legal — granted normally
307
+ assert.equal(userDoc?.consent?.legal?.status, 'granted', 'legal.status should be granted');
308
+ assert.equal(userDoc?.consent?.legal?.grantedAt?.source, 'signup', 'legal grantedAt.source should be signup-form');
309
+
310
+ // Marketing — revoked at signup
311
+ assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'marketing.status should be revoked');
312
+
313
+ // grantedAt MUST be all-null (never granted, even though client passed text)
314
+ assert.equal(userDoc?.consent?.marketing?.grantedAt?.timestamp, null, 'marketing grantedAt.timestamp should be null');
315
+ assert.equal(userDoc?.consent?.marketing?.grantedAt?.source, null, 'marketing grantedAt.source should be null');
316
+ assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, null, 'marketing grantedAt.text should be null (declined)');
317
+
318
+ // revokedAt MUST have signup-form-declined source + server time
319
+ assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, 'signup', 'marketing revokedAt.source should be signup-form-declined');
320
+ assert.ok(userDoc?.consent?.marketing?.revokedAt?.timestamp, 'marketing revokedAt.timestamp should be set');
321
+ assert.equal(typeof userDoc?.consent?.marketing?.revokedAt?.timestampUNIX, 'number', 'marketing revokedAt.timestampUNIX should be number');
322
+ assert.equal(userDoc?.consent?.marketing?.revokedAt?.text, null, 'marketing revokedAt.text should be null (decline has no message)');
323
+ },
324
+ },
325
+
326
+ {
327
+ name: 'consent-missing-defaults-to-revoked',
328
+ async run({ http, firestore, assert, accounts }) {
329
+ // Client sends NO consent field at all (legacy or malformed payload).
330
+ // Expected: both legal + marketing default to revoked. No crash, no marketing sync.
331
+ const signupResponse = await http.as('consent-missing').post('user/signup', {});
332
+
333
+ assert.isSuccess(signupResponse, `Signup should succeed even with no consent: ${JSON.stringify(signupResponse, null, 2)}`);
334
+
335
+ const userDoc = await firestore.get(`users/${accounts['consent-missing'].uid}`);
336
+
337
+ assert.ok(userDoc?.consent, 'consent object should exist');
338
+ assert.equal(userDoc?.consent?.legal?.status, 'revoked', 'legal.status should default to revoked');
339
+ assert.equal(userDoc?.consent?.legal?.grantedAt?.timestamp, null, 'legal grantedAt.timestamp should be null');
340
+ assert.equal(userDoc?.consent?.legal?.grantedAt?.source, null, 'legal grantedAt.source should be null');
341
+
342
+ assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'marketing.status should default to revoked');
343
+ assert.equal(userDoc?.consent?.marketing?.grantedAt?.timestamp, null, 'marketing grantedAt.timestamp should be null');
344
+
345
+ // Even when consent is missing entirely, the revokedAt block gets stamped with signup-form-declined.
346
+ // This ensures the user doc has a recorded decline event for audit.
347
+ assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, 'signup', 'marketing revokedAt.source should be signup-form-declined when consent missing');
348
+ },
349
+ },
350
+
237
351
  // --- Auth rejection test (at end per convention) ---
238
352
  {
239
353
  name: 'unauthenticated-rejected',