backend-manager 5.0.88 → 5.0.91

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 (39) hide show
  1. package/CLAUDE.md +133 -2
  2. package/README.md +1 -1
  3. package/package.json +5 -3
  4. package/src/cli/index.js +11 -0
  5. package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +75 -315
  7. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  9. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
  16. package/src/manager/helpers/user.js +2 -0
  17. package/src/manager/libraries/payment-processors/order-id.js +18 -0
  18. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  19. package/src/manager/libraries/payment-processors/stripe.js +88 -47
  20. package/src/manager/libraries/payment-processors/test.js +14 -11
  21. package/src/manager/routes/payments/intent/post.js +61 -7
  22. package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
  23. package/src/manager/routes/payments/intent/processors/test.js +18 -22
  24. package/src/manager/routes/payments/webhook/post.js +1 -1
  25. package/src/test/runner.js +11 -0
  26. package/src/test/test-accounts.js +20 -2
  27. package/templates/backend-manager-config.json +31 -12
  28. package/test/events/payments/journey-payments-cancel.js +2 -0
  29. package/test/events/payments/journey-payments-failure.js +2 -0
  30. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  31. package/test/events/payments/journey-payments-one-time.js +128 -0
  32. package/test/events/payments/journey-payments-plan-change.js +126 -0
  33. package/test/events/payments/journey-payments-suspend.js +2 -0
  34. package/test/events/payments/journey-payments-trial.js +4 -0
  35. package/test/events/payments/journey-payments-upgrade.js +20 -10
  36. package/test/helpers/stripe-to-unified.js +17 -0
  37. package/test/helpers/user.js +1 -0
  38. package/test/routes/payments/intent.js +10 -7
  39. /package/bin/{bem → backend-manager} +0 -0
@@ -1,3 +1,5 @@
1
+ const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
2
+
1
3
  /**
2
4
  * Stripe intent processor
3
5
  * Creates Stripe Checkout Sessions for subscription and one-time purchases
@@ -12,11 +14,11 @@ module.exports = {
12
14
  * @param {string} options.productId - Product ID from config (e.g., 'premium')
13
15
  * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
14
16
  * @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
15
- * @param {object} options.config - BEM config
16
- * @param {object} options.Manager - Manager instance
17
+ * @param {string} options.confirmationUrl - Success redirect URL
18
+ * @param {string} options.cancelUrl - Cancel redirect URL
17
19
  * @returns {object} { id, url, raw }
18
20
  */
19
- async createIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
21
+ async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant }) {
20
22
  // Initialize Stripe SDK
21
23
  const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
22
24
  const stripe = StripeLib.init();
@@ -24,65 +26,27 @@ module.exports = {
24
26
  const productType = product.type || 'subscription';
25
27
 
26
28
  // Resolve the Stripe price ID based on product type
27
- let priceId;
28
- if (productType === 'subscription') {
29
- priceId = product.prices?.[frequency]?.stripe;
30
- if (!priceId) {
31
- throw new Error(`No Stripe price found for ${productId}/${frequency}`);
32
- }
33
- } else {
34
- priceId = product.prices?.once?.stripe;
35
- if (!priceId) {
36
- throw new Error(`No Stripe price found for ${productId}/once`);
37
- }
38
- }
29
+ const priceId = resolvePriceId(product, productType, frequency);
39
30
 
40
31
  // Resolve or create Stripe customer (keyed by uid in metadata)
41
32
  const email = assistant?.getUser()?.auth?.email || null;
42
33
  const customer = await resolveCustomer(stripe, uid, email, assistant);
43
34
 
44
- assistant?.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
45
-
46
- // Build confirmation redirect URL
47
- const baseUrl = Manager.project.websiteUrl;
48
- const amount = productType === 'subscription'
49
- ? (product.prices?.[frequency]?.amount || 0)
50
- : (product.prices?.once?.amount || 0);
51
-
52
- let confirmationUrl = new URL('/payment/confirmation', baseUrl);
53
- confirmationUrl.searchParams.set('productId', productId);
54
- confirmationUrl.searchParams.set('productName', product.name || productId);
55
- confirmationUrl.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
56
- confirmationUrl.searchParams.set('currency', 'USD');
57
- confirmationUrl.searchParams.set('frequency', frequency || 'once');
58
- confirmationUrl.searchParams.set('paymentMethod', 'stripe');
59
- confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
60
- confirmationUrl.searchParams.set('track', 'true');
61
- // Append orderId as raw string — Stripe replaces {CHECKOUT_SESSION_ID} at redirect
62
- // time, but only if the braces are NOT URL-encoded
63
- confirmationUrl = `${confirmationUrl.toString()}&orderId={CHECKOUT_SESSION_ID}`;
64
-
65
- let cancelUrl = new URL('/payment/checkout', baseUrl);
66
- cancelUrl.searchParams.set('product', productId);
67
- if (frequency) {
68
- cancelUrl.searchParams.set('frequency', frequency);
69
- }
70
- cancelUrl.searchParams.set('payment', 'cancelled');
71
- cancelUrl = cancelUrl.toString();
35
+ assistant.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
72
36
 
73
37
  // Build session params based on product type
74
38
  let sessionParams;
75
39
 
76
40
  if (productType === 'subscription') {
77
- sessionParams = buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl });
41
+ sessionParams = buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl });
78
42
  } else {
79
- sessionParams = buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl });
43
+ sessionParams = buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, confirmationUrl, cancelUrl });
80
44
  }
81
45
 
82
46
  // Create the checkout session
83
47
  const session = await stripe.checkout.sessions.create(sessionParams);
84
48
 
85
- assistant?.log(`Stripe session created: sessionId=${session.id}, mode=${sessionParams.mode}, url=${session.url}`);
49
+ assistant.log(`Stripe session created: sessionId=${session.id}, mode=${sessionParams.mode}, url=${session.url}`);
86
50
 
87
51
  return {
88
52
  id: session.id,
@@ -95,7 +59,7 @@ module.exports = {
95
59
  /**
96
60
  * Build Stripe Checkout Session params for a subscription
97
61
  */
98
- function buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
62
+ function buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
99
63
  const sessionParams = {
100
64
  mode: 'subscription',
101
65
  customer: customer.id,
@@ -106,12 +70,14 @@ function buildSubscriptionSession({ priceId, customer, uid, productId, frequency
106
70
  subscription_data: {
107
71
  metadata: {
108
72
  uid: uid,
73
+ orderId: orderId,
109
74
  },
110
75
  },
111
76
  success_url: confirmationUrl,
112
77
  cancel_url: cancelUrl,
113
78
  metadata: {
114
79
  uid: uid,
80
+ orderId: orderId,
115
81
  productId: productId,
116
82
  frequency: frequency,
117
83
  },
@@ -128,7 +94,7 @@ function buildSubscriptionSession({ priceId, customer, uid, productId, frequency
128
94
  /**
129
95
  * Build Stripe Checkout Session params for a one-time payment
130
96
  */
131
- function buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl }) {
97
+ function buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, confirmationUrl, cancelUrl }) {
132
98
  return {
133
99
  mode: 'payment',
134
100
  customer: customer.id,
@@ -139,12 +105,14 @@ function buildOneTimeSession({ priceId, customer, uid, productId, product, confi
139
105
  payment_intent_data: {
140
106
  metadata: {
141
107
  uid: uid,
108
+ orderId: orderId,
142
109
  },
143
110
  },
144
111
  success_url: confirmationUrl,
145
112
  cancel_url: cancelUrl,
146
113
  metadata: {
147
114
  uid: uid,
115
+ orderId: orderId,
148
116
  productId: productId,
149
117
  },
150
118
  };
@@ -162,7 +130,7 @@ async function resolveCustomer(stripe, uid, email, assistant) {
162
130
 
163
131
  if (search.data.length > 0) {
164
132
  const existing = search.data[0];
165
- assistant?.log(`Found existing Stripe customer: ${existing.id}`);
133
+ assistant.log(`Found existing Stripe customer: ${existing.id}`);
166
134
  return existing;
167
135
  }
168
136
 
@@ -176,6 +144,6 @@ async function resolveCustomer(stripe, uid, email, assistant) {
176
144
  }
177
145
 
178
146
  const customer = await stripe.customers.create(params);
179
- assistant?.log(`Created new Stripe customer: ${customer.id}`);
147
+ assistant.log(`Created new Stripe customer: ${customer.id}`);
180
148
  return customer;
181
149
  }
@@ -1,4 +1,5 @@
1
1
  const fetch = require('wonderful-fetch');
2
+ const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
2
3
 
3
4
  /**
4
5
  * Test intent processor
@@ -16,12 +17,13 @@ module.exports = {
16
17
  * @param {string} options.productId - Product ID from config
17
18
  * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
18
19
  * @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
19
- * @param {object} options.config - BEM config
20
+ * @param {string} options.confirmationUrl - Success redirect URL
21
+ * @param {string} options.cancelUrl - Cancel redirect URL
20
22
  * @param {object} options.Manager - Manager instance
21
23
  * @param {object} options.assistant - Assistant instance
22
24
  * @returns {object} { id, url, raw }
23
25
  */
24
- async createIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
26
+ async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, Manager, assistant }) {
25
27
  // Guard: test processor is not available in production
26
28
  if (assistant.isProduction()) {
27
29
  throw new Error('Test processor is not available in production');
@@ -30,10 +32,10 @@ module.exports = {
30
32
  const productType = product.type || 'subscription';
31
33
 
32
34
  if (productType === 'subscription') {
33
- return createSubscriptionIntent({ uid, product, productId, frequency, trial, config, Manager, assistant });
35
+ return createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, Manager, assistant });
34
36
  }
35
37
 
36
- return createOneTimeIntent({ uid, product, productId, config, Manager, assistant });
38
+ return createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, Manager, assistant });
37
39
  },
38
40
  };
39
41
 
@@ -41,12 +43,9 @@ module.exports = {
41
43
  * Create a test subscription intent
42
44
  * Generates Stripe-shaped subscription + customer.subscription.created event
43
45
  */
44
- async function createSubscriptionIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
46
+ async function createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, Manager, assistant }) {
45
47
  // 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
- }
48
+ const priceId = resolvePriceId(product, 'subscription', frequency);
50
49
 
51
50
  // Generate IDs
52
51
  const timestamp = Date.now();
@@ -68,7 +67,7 @@ async function createSubscriptionIntent({ uid, product, productId, frequency, tr
68
67
  id: subscriptionId,
69
68
  object: 'subscription',
70
69
  status: trial && product.trial?.days ? 'trialing' : 'active',
71
- metadata: { uid },
70
+ metadata: { uid, orderId },
72
71
  plan: { id: priceId, interval },
73
72
  current_period_end: periodEnd,
74
73
  current_period_start: now,
@@ -94,14 +93,14 @@ async function createSubscriptionIntent({ uid, product, productId, frequency, tr
94
93
  data: { object: subscription },
95
94
  };
96
95
 
97
- assistant?.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
96
+ assistant.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
98
97
 
99
98
  // Auto-fire webhook
100
99
  fireWebhook({ event, Manager, assistant });
101
100
 
102
101
  return {
103
102
  id: sessionId,
104
- url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
103
+ url: confirmationUrl,
105
104
  raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
106
105
  };
107
106
  }
@@ -110,12 +109,9 @@ async function createSubscriptionIntent({ uid, product, productId, frequency, tr
110
109
  * Create a test one-time payment intent
111
110
  * Generates Stripe-shaped checkout session + checkout.session.completed event
112
111
  */
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
- }
112
+ async function createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, Manager, assistant }) {
113
+ // Validate that a price exists (resolvePriceId throws if not found)
114
+ resolvePriceId(product, 'one-time', null);
119
115
 
120
116
  // Generate IDs
121
117
  const timestamp = Date.now();
@@ -129,7 +125,7 @@ async function createOneTimeIntent({ uid, product, productId, config, Manager, a
129
125
  mode: 'payment',
130
126
  status: 'complete',
131
127
  payment_status: 'paid',
132
- metadata: { uid, productId },
128
+ metadata: { uid, orderId, productId },
133
129
  amount_total: Math.round((product.prices?.once?.amount || 0) * 100),
134
130
  currency: 'usd',
135
131
  };
@@ -141,14 +137,14 @@ async function createOneTimeIntent({ uid, product, productId, config, Manager, a
141
137
  data: { object: session },
142
138
  };
143
139
 
144
- assistant?.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
140
+ assistant.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
145
141
 
146
142
  // Auto-fire webhook
147
143
  fireWebhook({ event, Manager, assistant });
148
144
 
149
145
  return {
150
146
  id: sessionId,
151
- url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
147
+ url: confirmationUrl,
152
148
  raw: { id: sessionId, object: 'checkout.session', mode: 'payment' },
153
149
  };
154
150
  }
@@ -164,6 +160,6 @@ function fireWebhook({ event, Manager, assistant }) {
164
160
  body: event,
165
161
  timeout: 15000,
166
162
  }).catch((e) => {
167
- assistant?.log(`Test processor auto-webhook failed: ${e.message}`);
163
+ assistant.log(`Test processor auto-webhook failed: ${e.message}`);
168
164
  });
169
165
  }
@@ -82,7 +82,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
82
82
  processor: processor,
83
83
  status: 'pending',
84
84
  raw: raw,
85
- uid: uid,
85
+ owner: uid,
86
86
  event: {
87
87
  type: eventType,
88
88
  category: category,
@@ -1,3 +1,4 @@
1
+ const os = require('os');
1
2
  const path = require('path');
2
3
  const jetpack = require('fs-jetpack');
3
4
  const chalk = require('chalk');
@@ -52,6 +53,16 @@ class TestRunner {
52
53
  * Main run method
53
54
  */
54
55
  async run() {
56
+ // Abort if BEM is running from the user's home directory (e.g., accidental ~/node_modules install)
57
+ const homeDir = os.homedir();
58
+ if (__dirname.startsWith(path.join(homeDir, 'node_modules'))) {
59
+ console.error(chalk.red('\n ERROR: BEM is running from ~/node_modules (home directory install).'));
60
+ console.error(chalk.red(' This is likely an accidental global install that shadows local project copies.'));
61
+ console.error(chalk.red(` Fix: rm -rf ${path.join(homeDir, 'node_modules')} ${path.join(homeDir, 'package.json')} ${path.join(homeDir, 'package-lock.json')}`));
62
+ console.error(chalk.red(` Running from: ${__dirname}\n`));
63
+ process.exit(1);
64
+ }
65
+
55
66
  // Set testing flag to skip external API calls (emails, SendGrid)
56
67
  process.env.BEM_TESTING = 'true';
57
68
 
@@ -175,6 +175,24 @@ const JOURNEY_ACCOUNTS = {
175
175
  subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
176
176
  },
177
177
  },
178
+ 'journey-payments-plan-change': {
179
+ id: 'journey-payments-plan-change',
180
+ uid: '_test-journey-payments-plan-change',
181
+ email: '_test.journey-payments-plan-change@{domain}',
182
+ properties: {
183
+ roles: {},
184
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
185
+ },
186
+ },
187
+ 'journey-payments-one-time': {
188
+ id: 'journey-payments-one-time',
189
+ uid: '_test-journey-payments-one-time',
190
+ email: '_test.journey-payments-one-time@{domain}',
191
+ properties: {
192
+ roles: {},
193
+ subscription: { product: { id: 'basic' }, status: 'active' },
194
+ },
195
+ },
178
196
  };
179
197
 
180
198
  /**
@@ -363,13 +381,13 @@ async function deleteTestUsers(admin) {
363
381
 
364
382
  // Clean up payment-related collections for test accounts
365
383
  const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
366
- const paymentCollections = ['payments-subscriptions', 'payments-webhooks', 'payments-intents', 'payments-one-time'];
384
+ const paymentCollections = ['payments-orders', 'payments-webhooks', 'payments-intents'];
367
385
 
368
386
  await Promise.all(
369
387
  paymentCollections.map(async (collection) => {
370
388
  try {
371
389
  const snapshot = await admin.firestore().collection(collection)
372
- .where('uid', 'in', testUids)
390
+ .where('owner', 'in', testUids)
373
391
  .get();
374
392
 
375
393
  await Promise.all(
@@ -72,18 +72,37 @@
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
+ {
76
+ id: 'ultimate',
77
+ name: 'Ultimate',
78
+ type: 'subscription',
79
+ limits: {
80
+ requests: 10000,
81
+ },
82
+ prices: {
83
+ monthly: {
84
+ amount: 19.99,
85
+ stripe: null,
86
+ paypal: null,
87
+ },
88
+ annually: {
89
+ amount: 199.99,
90
+ stripe: null,
91
+ paypal: null,
92
+ },
93
+ },
94
+ },
95
+ {
96
+ id: 'credits-100',
97
+ name: '100 Credits',
98
+ type: 'one-time',
99
+ prices: {
100
+ once: {
101
+ amount: 9.99,
102
+ stripe: null,
103
+ },
104
+ },
105
+ },
87
106
  // Add more products/tiers here
88
107
  ],
89
108
  },
@@ -32,6 +32,7 @@ module.exports = {
32
32
  frequency: 'monthly',
33
33
  });
34
34
  assert.isSuccess(response, 'Intent should succeed');
35
+ state.orderId = response.data.orderId;
35
36
 
36
37
  // Wait for subscription to activate
37
38
  await waitFor(async () => {
@@ -42,6 +43,7 @@ module.exports = {
42
43
  const userDoc = await firestore.get(`users/${uid}`);
43
44
  assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
44
45
  assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
46
+ assert.equal(userDoc.subscription?.payment?.orderId, state.orderId, 'Order ID should match intent');
45
47
 
46
48
  state.subscriptionId = userDoc.subscription.payment.resourceId;
47
49
  },
@@ -35,6 +35,7 @@ module.exports = {
35
35
  frequency: 'monthly',
36
36
  });
37
37
  assert.isSuccess(response, 'Intent should succeed');
38
+ state.orderId = response.data.orderId;
38
39
 
39
40
  // Wait for subscription to activate
40
41
  await waitFor(async () => {
@@ -45,6 +46,7 @@ module.exports = {
45
46
  const userDoc = await firestore.get(`users/${uid}`);
46
47
  assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
47
48
  assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
49
+ assert.equal(userDoc.subscription?.payment?.orderId, state.orderId, 'Order ID should match intent');
48
50
 
49
51
  state.subscriptionId = userDoc.subscription.payment.resourceId;
50
52
  },
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Test: Payment Journey - One-Time Payment Failure
3
+ * Simulates: invoice.payment_failed for a non-subscription invoice → purchase-failed transition
4
+ *
5
+ * This verifies the webhook routing for one-time invoice failures:
6
+ * invoice.payment_failed with no subscription billing_reason → category: 'one-time'
7
+ *
8
+ * Uses the journey-payments-one-time account (one-time events don't modify subscription state)
9
+ */
10
+ module.exports = {
11
+ description: 'Payment journey: one-time invoice.payment_failed → purchase-failed',
12
+ type: 'suite',
13
+ timeout: 30000,
14
+
15
+ tests: [
16
+ {
17
+ name: 'resolve-one-time-product',
18
+ async run({ accounts, assert, state, config }) {
19
+ const uid = accounts['journey-payments-one-time'].uid;
20
+
21
+ // Resolve first one-time product from config
22
+ const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
23
+ assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
24
+
25
+ state.uid = uid;
26
+ state.productId = oneTimeProduct.id;
27
+ },
28
+ },
29
+
30
+ {
31
+ name: 'send-one-time-payment-failed',
32
+ async run({ http, assert, state, config }) {
33
+ state.eventId = `_test-evt-journey-onetime-fail-${Date.now()}`;
34
+ state.invoiceId = `_test-inv-onetime-fail-${Date.now()}`;
35
+ state.orderId = `0000-0000-0000`; // Fake orderId for test
36
+
37
+ // Send invoice.payment_failed with a non-subscription billing reason
38
+ // This routes to category: 'one-time' in the webhook parser
39
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
40
+ id: state.eventId,
41
+ type: 'invoice.payment_failed',
42
+ data: {
43
+ object: {
44
+ id: state.invoiceId,
45
+ object: 'invoice',
46
+ billing_reason: 'manual',
47
+ amount_due: 999,
48
+ amount_paid: 0,
49
+ status: 'open',
50
+ metadata: {
51
+ uid: state.uid,
52
+ orderId: state.orderId,
53
+ productId: state.productId,
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ assert.isSuccess(response, 'Webhook should be accepted');
60
+ },
61
+ },
62
+
63
+ {
64
+ name: 'webhook-categorized-as-one-time',
65
+ async run({ firestore, assert, state, waitFor }) {
66
+ await waitFor(async () => {
67
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
68
+ return doc?.status === 'completed' || doc?.status === 'failed';
69
+ }, 15000, 500);
70
+
71
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
72
+ assert.ok(webhookDoc, 'Webhook doc should exist');
73
+ assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
74
+ assert.equal(webhookDoc.event?.resourceType, 'invoice', 'Resource type should be invoice');
75
+ assert.equal(webhookDoc.transition, 'purchase-failed', 'Transition should be purchase-failed');
76
+ },
77
+ },
78
+
79
+ {
80
+ name: 'order-doc-created-with-failure',
81
+ async run({ firestore, assert, state }) {
82
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
83
+
84
+ assert.ok(orderDoc, 'Order doc should exist');
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
+ },
89
+ },
90
+
91
+ {
92
+ name: 'subscription-unchanged',
93
+ async run({ firestore, assert, state }) {
94
+ // One-time payment failures must NOT modify subscription state
95
+ const userDoc = await firestore.get(`users/${state.uid}`);
96
+
97
+ assert.equal(
98
+ userDoc.subscription?.product?.id,
99
+ 'basic',
100
+ 'Subscription should remain basic after one-time failure',
101
+ );
102
+ },
103
+ },
104
+ ],
105
+ };
@@ -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
+ };