backend-manager 5.0.85 → 5.0.87

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 (40) hide show
  1. package/CLAUDE.md +53 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/base-command.js +5 -1
  4. package/src/cli/commands/serve.js +1 -2
  5. package/src/manager/cron/daily/ghostii-auto-publisher.js +10 -19
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +351 -56
  7. package/src/manager/events/firestore/payments-webhooks/transitions/index.js +148 -0
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +16 -0
  9. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +15 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +15 -0
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +18 -0
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +15 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +14 -0
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +16 -0
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +16 -0
  16. package/src/manager/index.js +26 -36
  17. package/src/manager/libraries/{stripe.js → payment-processors/stripe.js} +57 -2
  18. package/src/manager/libraries/payment-processors/test.js +141 -0
  19. package/src/manager/routes/app/get.js +5 -22
  20. package/src/manager/routes/payments/intent/post.js +38 -23
  21. package/src/manager/routes/payments/intent/processors/stripe.js +112 -44
  22. package/src/manager/routes/payments/intent/processors/test.js +139 -76
  23. package/src/manager/routes/payments/webhook/post.js +14 -5
  24. package/src/manager/routes/payments/webhook/processors/stripe.js +75 -9
  25. package/src/manager/schemas/payments/intent/post.js +1 -1
  26. package/src/test/test-accounts.js +10 -1
  27. package/templates/backend-manager-config.json +16 -4
  28. package/test/events/payments/journey-payments-cancel.js +6 -0
  29. package/test/events/payments/journey-payments-failure.js +114 -0
  30. package/test/events/payments/journey-payments-suspend.js +6 -0
  31. package/test/events/payments/journey-payments-trial.js +12 -0
  32. package/test/events/payments/journey-payments-upgrade.js +17 -0
  33. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  34. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  35. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  36. package/test/helpers/stripe-parse-webhook.js +447 -0
  37. package/test/helpers/stripe-to-unified.js +59 -59
  38. package/test/routes/payments/intent.js +3 -3
  39. package/test/routes/payments/webhook.js +2 -2
  40. package/src/manager/libraries/test.js +0 -27
@@ -12,95 +12,158 @@ module.exports = {
12
12
  *
13
13
  * @param {object} options
14
14
  * @param {string} options.uid - User's UID
15
+ * @param {object} options.product - Full product object from config
15
16
  * @param {string} options.productId - Product ID from config
16
- * @param {string} options.frequency - 'monthly' or 'annually'
17
- * @param {boolean} options.trial - Whether to include a trial period
17
+ * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
18
+ * @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
18
19
  * @param {object} options.config - BEM config
19
20
  * @param {object} options.Manager - Manager instance
20
21
  * @param {object} options.assistant - Assistant instance
21
22
  * @returns {object} { id, url, raw }
22
23
  */
23
- async createIntent({ uid, productId, frequency, trial, config, Manager, assistant }) {
24
+ async createIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
24
25
  // Guard: test processor is not available in production
25
26
  if (assistant.isProduction()) {
26
27
  throw new Error('Test processor is not available in production');
27
28
  }
28
29
 
29
- // Find the product in config
30
- const product = (config.payment?.products || []).find(p => p.id === productId);
31
- if (!product) {
32
- throw new Error(`Product '${productId}' not found in config`);
33
- }
34
-
35
- // Get the price ID for the requested frequency (needed for product resolution in toUnified)
36
- const priceId = product.prices?.[frequency]?.stripe;
37
- if (!priceId) {
38
- throw new Error(`No Stripe price found for ${productId}/${frequency}`);
39
- }
30
+ const productType = product.type || 'subscription';
40
31
 
41
- // Generate IDs
42
- const timestamp = Date.now();
43
- const sessionId = `_test-cs-${timestamp}`;
44
- const subscriptionId = `_test-sub-${timestamp}`;
45
- const eventId = `_test-evt-${timestamp}`;
46
-
47
- // Map frequency to Stripe interval
48
- const interval = frequency === 'annually' ? 'year' : 'month';
49
-
50
- // Build timestamps
51
- const now = Math.floor(timestamp / 1000);
52
- const periodEnd = frequency === 'annually'
53
- ? now + (365 * 86400)
54
- : now + (30 * 86400);
55
-
56
- // Build Stripe-shaped subscription object
57
- const subscription = {
58
- id: subscriptionId,
59
- object: 'subscription',
60
- status: trial && product.trial?.days ? 'trialing' : 'active',
61
- metadata: { uid },
62
- plan: { id: priceId, interval },
63
- current_period_end: periodEnd,
64
- current_period_start: now,
65
- start_date: now,
66
- cancel_at_period_end: false,
67
- cancel_at: null,
68
- canceled_at: null,
69
- trial_start: null,
70
- trial_end: null,
71
- };
72
-
73
- // Add trial dates if applicable
74
- if (trial && product.trial?.days) {
75
- subscription.trial_start = now;
76
- subscription.trial_end = now + (product.trial.days * 86400);
77
- subscription.current_period_end = subscription.trial_end;
32
+ if (productType === 'subscription') {
33
+ return createSubscriptionIntent({ uid, product, productId, frequency, trial, config, Manager, assistant });
78
34
  }
79
35
 
80
- // Build Stripe-shaped event
81
- const event = {
82
- id: eventId,
83
- type: 'customer.subscription.created',
84
- data: { object: subscription },
85
- };
86
-
87
- assistant?.log(`Test intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
88
-
89
- // Auto-fire webhook (fire-and-forget — don't block intent response)
90
- const webhookUrl = `${Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
91
- fetch(webhookUrl, {
92
- method: 'POST',
93
- response: 'json',
94
- body: event,
95
- timeout: 15000,
96
- }).catch((e) => {
97
- assistant?.log(`Test processor auto-webhook failed: ${e.message}`);
98
- });
99
-
100
- return {
101
- id: sessionId,
102
- url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
103
- raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
104
- };
36
+ return createOneTimeIntent({ uid, product, productId, config, Manager, assistant });
105
37
  },
106
38
  };
39
+
40
+ /**
41
+ * Create a test subscription intent
42
+ * Generates Stripe-shaped subscription + customer.subscription.created event
43
+ */
44
+ async function createSubscriptionIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
45
+ // Get the price ID for the requested frequency
46
+ const priceId = product.prices?.[frequency]?.stripe;
47
+ if (!priceId) {
48
+ throw new Error(`No Stripe price found for ${productId}/${frequency}`);
49
+ }
50
+
51
+ // Generate IDs
52
+ const timestamp = Date.now();
53
+ const sessionId = `_test-cs-${timestamp}`;
54
+ const subscriptionId = `_test-sub-${timestamp}`;
55
+ const eventId = `_test-evt-${timestamp}`;
56
+
57
+ // Map frequency to Stripe interval
58
+ const interval = frequency === 'annually' ? 'year' : 'month';
59
+
60
+ // Build timestamps
61
+ const now = Math.floor(timestamp / 1000);
62
+ const periodEnd = frequency === 'annually'
63
+ ? now + (365 * 86400)
64
+ : now + (30 * 86400);
65
+
66
+ // Build Stripe-shaped subscription object
67
+ const subscription = {
68
+ id: subscriptionId,
69
+ object: 'subscription',
70
+ status: trial && product.trial?.days ? 'trialing' : 'active',
71
+ metadata: { uid },
72
+ plan: { id: priceId, interval },
73
+ current_period_end: periodEnd,
74
+ current_period_start: now,
75
+ start_date: now,
76
+ cancel_at_period_end: false,
77
+ cancel_at: null,
78
+ canceled_at: null,
79
+ trial_start: null,
80
+ trial_end: null,
81
+ };
82
+
83
+ // Add trial dates if applicable
84
+ if (trial && product.trial?.days) {
85
+ subscription.trial_start = now;
86
+ subscription.trial_end = now + (product.trial.days * 86400);
87
+ subscription.current_period_end = subscription.trial_end;
88
+ }
89
+
90
+ // Build Stripe-shaped event
91
+ const event = {
92
+ id: eventId,
93
+ type: 'customer.subscription.created',
94
+ data: { object: subscription },
95
+ };
96
+
97
+ assistant?.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
98
+
99
+ // Auto-fire webhook
100
+ fireWebhook({ event, Manager, assistant });
101
+
102
+ return {
103
+ id: sessionId,
104
+ url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
105
+ raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Create a test one-time payment intent
111
+ * Generates Stripe-shaped checkout session + checkout.session.completed event
112
+ */
113
+ async function createOneTimeIntent({ uid, product, productId, config, Manager, assistant }) {
114
+ // Get the price ID for one-time purchase
115
+ const priceId = product.prices?.once?.stripe;
116
+ if (!priceId) {
117
+ throw new Error(`No Stripe price found for ${productId}/once`);
118
+ }
119
+
120
+ // Generate IDs
121
+ const timestamp = Date.now();
122
+ const sessionId = `_test-cs-${timestamp}`;
123
+ const eventId = `_test-evt-${timestamp}`;
124
+
125
+ // Build Stripe-shaped checkout session object
126
+ const session = {
127
+ id: sessionId,
128
+ object: 'checkout.session',
129
+ mode: 'payment',
130
+ status: 'complete',
131
+ payment_status: 'paid',
132
+ metadata: { uid, productId },
133
+ amount_total: Math.round((product.prices?.once?.amount || 0) * 100),
134
+ currency: 'usd',
135
+ };
136
+
137
+ // Build Stripe-shaped event
138
+ const event = {
139
+ id: eventId,
140
+ type: 'checkout.session.completed',
141
+ data: { object: session },
142
+ };
143
+
144
+ assistant?.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
145
+
146
+ // Auto-fire webhook
147
+ fireWebhook({ event, Manager, assistant });
148
+
149
+ return {
150
+ id: sessionId,
151
+ url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
152
+ raw: { id: sessionId, object: 'checkout.session', mode: 'payment' },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Fire-and-forget webhook to trigger the full pipeline
158
+ */
159
+ function fireWebhook({ event, Manager, assistant }) {
160
+ const webhookUrl = `${Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
161
+ fetch(webhookUrl, {
162
+ method: 'POST',
163
+ response: 'json',
164
+ body: event,
165
+ timeout: 15000,
166
+ }).catch((e) => {
167
+ assistant?.log(`Test processor auto-webhook failed: ${e.message}`);
168
+ });
169
+ }
@@ -7,7 +7,7 @@ const powertools = require('node-powertools');
7
7
  * The Firestore onWrite trigger handles async processing
8
8
  *
9
9
  * This handler is processor-agnostic. Each processor module defines:
10
- * - parseWebhook(req) — extracts { eventId, eventType, raw, uid }
10
+ * - parseWebhook(req) — extracts { eventId, eventType, category, resourceType, resourceId, raw, uid }
11
11
  * - isSupported(eventType) — returns true for events we should process
12
12
  */
13
13
  module.exports = async ({ assistant, Manager, libraries }) => {
@@ -45,13 +45,19 @@ module.exports = async ({ assistant, Manager, libraries }) => {
45
45
  return assistant.respond(`Failed to parse webhook: ${e.message}`, { code: 400 });
46
46
  }
47
47
 
48
- const { eventId, eventType, raw, uid } = parsed;
48
+ const { eventId, eventType, category, resourceType, resourceId, raw, uid } = parsed;
49
49
 
50
- assistant.log(`Parsed webhook: eventId=${eventId}, eventType=${eventType}, uid=${uid || 'null'}`);
50
+ assistant.log(`Parsed webhook: eventId=${eventId}, eventType=${eventType}, category=${category || 'null'}, resourceType=${resourceType || 'null'}, uid=${uid || 'null'}`);
51
51
 
52
52
  // Let the processor decide if this event type is relevant
53
53
  if (processorModule.isSupported && !processorModule.isSupported(eventType)) {
54
- assistant.log(`Ignoring event type: ${eventType}`);
54
+ assistant.log(`Ignoring unsupported event type: ${eventType}`);
55
+ return assistant.respond({ received: true, ignored: true });
56
+ }
57
+
58
+ // Skip events with no category (e.g., checkout.session.completed for subscription mode)
59
+ if (!category) {
60
+ assistant.log(`Ignoring event with no category: ${eventType}`);
55
61
  return assistant.respond({ received: true, ignored: true });
56
62
  }
57
63
 
@@ -79,6 +85,9 @@ module.exports = async ({ assistant, Manager, libraries }) => {
79
85
  uid: uid,
80
86
  event: {
81
87
  type: eventType,
88
+ category: category,
89
+ resourceType: resourceType,
90
+ resourceId: resourceId,
82
91
  },
83
92
  error: null,
84
93
  metadata: {
@@ -93,7 +102,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
93
102
  },
94
103
  });
95
104
 
96
- assistant.log(`Saved payments-webhooks/${eventId}: eventType=${eventType}, processor=${processor}, uid=${uid}`);
105
+ assistant.log(`Saved payments-webhooks/${eventId}: eventType=${eventType}, category=${category}, processor=${processor}, uid=${uid}`);
97
106
 
98
107
  // Return 200 immediately
99
108
  return assistant.respond({ received: true });
@@ -1,13 +1,25 @@
1
1
  /**
2
2
  * Stripe webhook processor
3
- * Extracts and validates webhook event data from Stripe
3
+ * Extracts, validates, and categorizes webhook event data from Stripe
4
+ *
5
+ * Each event is mapped to a category (subscription or one-time) and includes
6
+ * the resource type + ID needed to fetch the latest state from Stripe's API.
4
7
  */
5
8
 
6
- // Stripe event types we process add new ones here as needed
9
+ // Events we process, mapped to their default category
10
+ // Some events (invoice.payment_failed, checkout.session.completed) require
11
+ // inspecting the payload to determine the actual category
7
12
  const SUPPORTED_EVENTS = new Set([
13
+ // Subscription lifecycle
8
14
  'customer.subscription.created',
9
15
  'customer.subscription.updated',
10
16
  'customer.subscription.deleted',
17
+
18
+ // Payment failures (could be subscription or one-time)
19
+ 'invoice.payment_failed',
20
+
21
+ // Checkout completion (could be subscription or one-time)
22
+ 'checkout.session.completed',
11
23
  ]);
12
24
 
13
25
  module.exports = {
@@ -20,10 +32,13 @@ module.exports = {
20
32
 
21
33
  /**
22
34
  * Parse a Stripe webhook request
23
- * Extracts the event data, event type, and resolves the UID from metadata
35
+ * Extracts event data and determines category, resource type, resource ID, and UID
24
36
  *
25
37
  * @param {object} req - The raw HTTP request
26
- * @returns {object} { eventId, eventType, raw, uid }
38
+ * @returns {object} { eventId, eventType, category, resourceType, resourceId, raw, uid }
39
+ * - category: 'subscription' | 'one-time' | null (null = skip)
40
+ * - resourceType: 'subscription' | 'invoice' | 'session'
41
+ * - resourceId: ID to fetch from processor API
27
42
  */
28
43
  parseWebhook(req) {
29
44
  const event = req.body;
@@ -33,16 +48,67 @@ module.exports = {
33
48
  throw new Error('Invalid Stripe webhook payload');
34
49
  }
35
50
 
36
- // The subscription object is typically in event.data.object
37
51
  const dataObject = event.data?.object || {};
52
+ const eventType = event.type;
53
+
54
+ // Resolve category, resource info, and UID based on event type
55
+ let category = null;
56
+ let resourceType = null;
57
+ let resourceId = null;
58
+ let uid = null;
59
+
60
+ if (eventType.startsWith('customer.subscription.')) {
61
+ // Subscription lifecycle events — always subscription category
62
+ category = 'subscription';
63
+ resourceType = 'subscription';
64
+ resourceId = dataObject.id;
65
+ uid = dataObject.metadata?.uid || null;
38
66
 
39
- // Resolve UID from subscription metadata
40
- // When creating checkout sessions, we set metadata.uid on the subscription
41
- const uid = dataObject.metadata?.uid || null;
67
+ } else if (eventType === 'invoice.payment_failed') {
68
+ // Payment failure inspect billing_reason to determine category
69
+ const billingReason = dataObject.billing_reason || '';
70
+ const subscriptionId = dataObject.parent?.subscription_details?.subscription
71
+ || dataObject.subscription
72
+ || null;
73
+
74
+ if (billingReason.startsWith('subscription') && subscriptionId) {
75
+ // Subscription-related invoice failure
76
+ category = 'subscription';
77
+ resourceType = 'subscription';
78
+ resourceId = subscriptionId;
79
+ uid = dataObject.parent?.subscription_details?.metadata?.uid
80
+ || dataObject.subscription_details?.metadata?.uid
81
+ || dataObject.metadata?.uid
82
+ || null;
83
+ } else {
84
+ // One-time invoice failure
85
+ category = 'one-time';
86
+ resourceType = 'invoice';
87
+ resourceId = dataObject.id;
88
+ uid = dataObject.metadata?.uid || null;
89
+ }
90
+
91
+ } else if (eventType === 'checkout.session.completed') {
92
+ const mode = dataObject.mode;
93
+
94
+ if (mode === 'subscription') {
95
+ // Subscription checkout — skip, subscription events handle this
96
+ category = null;
97
+ } else if (mode === 'payment') {
98
+ // One-time payment checkout
99
+ category = 'one-time';
100
+ resourceType = 'session';
101
+ resourceId = dataObject.id;
102
+ uid = dataObject.metadata?.uid || null;
103
+ }
104
+ }
42
105
 
43
106
  return {
44
107
  eventId: event.id,
45
- eventType: event.type,
108
+ eventType: eventType,
109
+ category: category,
110
+ resourceType: resourceType,
111
+ resourceId: resourceId,
46
112
  raw: event,
47
113
  uid: uid,
48
114
  };
@@ -13,7 +13,7 @@ module.exports = () => ({
13
13
  },
14
14
  frequency: {
15
15
  types: ['string'],
16
- required: true,
16
+ default: null,
17
17
  },
18
18
  trial: {
19
19
  types: ['boolean'],
@@ -166,6 +166,15 @@ const JOURNEY_ACCOUNTS = {
166
166
  subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via trial webhook
167
167
  },
168
168
  },
169
+ 'journey-payments-failure': {
170
+ id: 'journey-payments-failure',
171
+ uid: '_test-journey-payments-failure',
172
+ email: '_test.journey-payments-failure@{domain}',
173
+ properties: {
174
+ roles: {},
175
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
176
+ },
177
+ },
169
178
  };
170
179
 
171
180
  /**
@@ -354,7 +363,7 @@ async function deleteTestUsers(admin) {
354
363
 
355
364
  // Clean up payment-related collections for test accounts
356
365
  const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
357
- const paymentCollections = ['payments-subscriptions', 'payments-webhooks', 'payments-intents'];
366
+ const paymentCollections = ['payments-subscriptions', 'payments-webhooks', 'payments-intents', 'payments-one-time'];
358
367
 
359
368
  await Promise.all(
360
369
  paymentCollections.map(async (collection) => {
@@ -62,16 +62,28 @@
62
62
  prices: {
63
63
  monthly: {
64
64
  amount: 4.99,
65
- stripe: 'price_xxx',
66
- paypal: 'P-xxx',
65
+ stripe: null,
66
+ paypal: null,
67
67
  },
68
68
  annually: {
69
69
  amount: 49.99,
70
- stripe: 'price_yyy',
71
- paypal: 'P-yyy',
70
+ stripe: null,
71
+ paypal: null,
72
72
  },
73
73
  },
74
74
  },
75
+ // Example one-time product:
76
+ // {
77
+ // id: 'credits-100',
78
+ // name: '100 Credits',
79
+ // type: 'one-time',
80
+ // prices: {
81
+ // once: {
82
+ // amount: 9.99,
83
+ // stripe: null,
84
+ // },
85
+ // },
86
+ // },
75
87
  // Add more products/tiers here
76
88
  ],
77
89
  },
@@ -89,6 +89,9 @@ module.exports = {
89
89
  return doc?.status === 'completed';
90
90
  }, 15000, 500);
91
91
 
92
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId1}`);
93
+ assert.equal(webhookDoc.transition, 'cancellation-requested', 'Transition should be cancellation-requested');
94
+
92
95
  const userDoc = await firestore.get(`users/${state.uid}`);
93
96
 
94
97
  assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
@@ -134,6 +137,9 @@ module.exports = {
134
137
  return doc?.status === 'completed';
135
138
  }, 15000, 500);
136
139
 
140
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId2}`);
141
+ assert.equal(webhookDoc.transition, 'subscription-cancelled', 'Transition should be subscription-cancelled');
142
+
137
143
  const userDoc = await firestore.get(`users/${state.uid}`);
138
144
 
139
145
  assert.equal(userDoc.subscription.status, 'cancelled', 'Status should be cancelled');
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Test: Payment Journey - Invoice Payment Failure
3
+ * Simulates: basic → paid subscription → invoice.payment_failed → suspended
4
+ *
5
+ * Unlike journey-payments-suspend (which uses customer.subscription.updated with past_due),
6
+ * this test uses the invoice.payment_failed event with billing_reason: subscription_cycle.
7
+ * This verifies the new parseWebhook routing that determines category from invoice data.
8
+ *
9
+ * Product-agnostic: resolves the first paid product from config.payment.products
10
+ */
11
+ module.exports = {
12
+ description: 'Payment journey: paid → invoice.payment_failed → suspended',
13
+ type: 'suite',
14
+ timeout: 30000,
15
+
16
+ tests: [
17
+ {
18
+ name: 'setup-paid-subscription',
19
+ async run({ accounts, firestore, assert, state, config, http, waitFor }) {
20
+ const uid = accounts['journey-payments-failure'].uid;
21
+
22
+ // Resolve first paid subscription product
23
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
24
+ assert.ok(paidProduct, 'Config should have at least one paid subscription product');
25
+
26
+ state.uid = uid;
27
+ state.paidProductId = paidProduct.id;
28
+ state.paidProductName = paidProduct.name;
29
+ state.paidPriceId = paidProduct.prices.monthly.stripe;
30
+
31
+ // Create subscription via test intent
32
+ const response = await http.as('journey-payments-failure').post('payments/intent', {
33
+ processor: 'test',
34
+ productId: paidProduct.id,
35
+ frequency: 'monthly',
36
+ });
37
+ assert.isSuccess(response, 'Intent should succeed');
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 === paidProduct.id;
43
+ }, 15000, 500);
44
+
45
+ const userDoc = await firestore.get(`users/${uid}`);
46
+ assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.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-invoice-payment-failed',
55
+ async run({ http, assert, state, config }) {
56
+ state.eventId = `_test-evt-journey-failure-${Date.now()}`;
57
+
58
+ // Send invoice.payment_failed with subscription billing reason
59
+ // This tests the new parseWebhook routing: billing_reason=subscription_cycle → subscription category
60
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
61
+ id: state.eventId,
62
+ type: 'invoice.payment_failed',
63
+ data: {
64
+ object: {
65
+ id: `in_test_failure_${Date.now()}`,
66
+ object: 'invoice',
67
+ billing_reason: 'subscription_cycle',
68
+ amount_due: 999,
69
+ amount_paid: 0,
70
+ status: 'open',
71
+ parent: {
72
+ subscription_details: {
73
+ subscription: state.subscriptionId,
74
+ metadata: { uid: state.uid },
75
+ },
76
+ type: 'subscription_details',
77
+ },
78
+ },
79
+ },
80
+ });
81
+
82
+ assert.isSuccess(response, 'Webhook should be accepted');
83
+ },
84
+ },
85
+
86
+ {
87
+ name: 'verify-webhook-categorized-as-subscription',
88
+ async run({ firestore, assert, state, waitFor }) {
89
+ // Wait for webhook doc to be saved
90
+ await waitFor(async () => {
91
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
92
+ return doc?.status === 'completed' || doc?.status === 'failed';
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.event?.category, 'subscription', 'Category should be subscription');
98
+ assert.equal(webhookDoc.event?.resourceType, 'subscription', 'Resource type should be subscription');
99
+ assert.equal(webhookDoc.event?.resourceId, state.subscriptionId, 'Resource ID should be subscription ID');
100
+ assert.equal(webhookDoc.transition, 'payment-failed', 'Transition should be payment-failed');
101
+ },
102
+ },
103
+
104
+ {
105
+ name: 'subscription-suspended',
106
+ async run({ firestore, assert, state }) {
107
+ const userDoc = await firestore.get(`users/${state.uid}`);
108
+
109
+ assert.equal(userDoc.subscription.status, 'suspended', 'Status should be suspended after payment failure');
110
+ assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
111
+ },
112
+ },
113
+ ],
114
+ };
@@ -85,6 +85,9 @@ module.exports = {
85
85
  return doc?.status === 'completed';
86
86
  }, 15000, 500);
87
87
 
88
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId1}`);
89
+ assert.equal(webhookDoc.transition, 'payment-failed', 'Transition should be payment-failed');
90
+
88
91
  const userDoc = await firestore.get(`users/${state.uid}`);
89
92
 
90
93
  assert.equal(userDoc.subscription.status, 'suspended', 'Status should be suspended');
@@ -133,6 +136,9 @@ module.exports = {
133
136
  return doc?.status === 'completed';
134
137
  }, 15000, 500);
135
138
 
139
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId2}`);
140
+ assert.equal(webhookDoc.transition, 'payment-recovered', 'Transition should be payment-recovered');
141
+
136
142
  const userDoc = await firestore.get(`users/${state.uid}`);
137
143
 
138
144
  assert.equal(userDoc.subscription.status, 'active', 'Status should be active again');
@@ -45,6 +45,9 @@ module.exports = {
45
45
  assert.ok(response.data.id, 'Should return intent ID');
46
46
 
47
47
  state.intentId = response.data.id;
48
+
49
+ // Derive webhook event ID from intent ID (same timestamp)
50
+ state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
48
51
  },
49
52
  },
50
53
 
@@ -64,6 +67,11 @@ module.exports = {
64
67
  assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should be claimed');
65
68
 
66
69
  state.subscriptionId = userDoc.subscription.payment.resourceId;
70
+
71
+ // Verify the auto-fired webhook triggered new-subscription (trial is a property, not a separate transition)
72
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
73
+ assert.ok(webhookDoc, 'Webhook doc should exist');
74
+ assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription (trial detected inside handler)');
67
75
  },
68
76
  },
69
77
 
@@ -108,6 +116,10 @@ module.exports = {
108
116
  return doc?.status === 'completed';
109
117
  }, 15000, 500);
110
118
 
119
+ // Trial → active paid is the same status/product, so no transition fires
120
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId2}`);
121
+ assert.equal(webhookDoc.transition, null, 'No transition (same status/product, trial already claimed)');
122
+
111
123
  const userDoc = await firestore.get(`users/${state.uid}`);
112
124
 
113
125
  assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);