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.
- package/CLAUDE.md +133 -2
- package/README.md +1 -1
- package/package.json +5 -3
- package/src/cli/index.js +11 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +75 -315
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
- package/src/manager/helpers/user.js +2 -0
- package/src/manager/libraries/payment-processors/order-id.js +18 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +88 -47
- package/src/manager/libraries/payment-processors/test.js +14 -11
- package/src/manager/routes/payments/intent/post.js +61 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
- package/src/manager/routes/payments/intent/processors/test.js +18 -22
- package/src/manager/routes/payments/webhook/post.js +1 -1
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +20 -2
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-cancel.js +2 -0
- package/test/events/payments/journey-payments-failure.js +2 -0
- package/test/events/payments/journey-payments-one-time-failure.js +105 -0
- package/test/events/payments/journey-payments-one-time.js +128 -0
- package/test/events/payments/journey-payments-plan-change.js +126 -0
- package/test/events/payments/journey-payments-suspend.js +2 -0
- package/test/events/payments/journey-payments-trial.js +4 -0
- package/test/events/payments/journey-payments-upgrade.js +20 -10
- package/test/helpers/stripe-to-unified.js +17 -0
- package/test/helpers/user.js +1 -0
- package/test/routes/payments/intent.js +10 -7
- /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 {
|
|
16
|
-
* @param {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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,
|
|
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,
|
|
35
|
+
return createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, Manager, assistant });
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
return createOneTimeIntent({ uid, product, productId,
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
114
|
-
//
|
|
115
|
-
|
|
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
|
|
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:
|
|
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
|
|
163
|
+
assistant.log(`Test processor auto-webhook failed: ${e.message}`);
|
|
168
164
|
});
|
|
169
165
|
}
|
package/src/test/runner.js
CHANGED
|
@@ -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-
|
|
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('
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
};
|