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.
- package/CLAUDE.md +53 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +5 -1
- package/src/cli/commands/serve.js +1 -2
- package/src/manager/cron/daily/ghostii-auto-publisher.js +10 -19
- package/src/manager/events/firestore/payments-webhooks/on-write.js +351 -56
- package/src/manager/events/firestore/payments-webhooks/transitions/index.js +148 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +16 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +18 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +14 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +16 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +16 -0
- package/src/manager/index.js +26 -36
- package/src/manager/libraries/{stripe.js → payment-processors/stripe.js} +57 -2
- package/src/manager/libraries/payment-processors/test.js +141 -0
- package/src/manager/routes/app/get.js +5 -22
- package/src/manager/routes/payments/intent/post.js +38 -23
- package/src/manager/routes/payments/intent/processors/stripe.js +112 -44
- package/src/manager/routes/payments/intent/processors/test.js +139 -76
- package/src/manager/routes/payments/webhook/post.js +14 -5
- package/src/manager/routes/payments/webhook/processors/stripe.js +75 -9
- package/src/manager/schemas/payments/intent/post.js +1 -1
- package/src/test/test-accounts.js +10 -1
- package/templates/backend-manager-config.json +16 -4
- package/test/events/payments/journey-payments-cancel.js +6 -0
- package/test/events/payments/journey-payments-failure.js +114 -0
- package/test/events/payments/journey-payments-suspend.js +6 -0
- package/test/events/payments/journey-payments-trial.js +12 -0
- package/test/events/payments/journey-payments-upgrade.js +17 -0
- package/test/fixtures/stripe/checkout-session-completed.json +130 -0
- package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
- package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
- package/test/helpers/stripe-parse-webhook.js +447 -0
- package/test/helpers/stripe-to-unified.js +59 -59
- package/test/routes/payments/intent.js +3 -3
- package/test/routes/payments/webhook.js +2 -2
- 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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
108
|
+
eventType: eventType,
|
|
109
|
+
category: category,
|
|
110
|
+
resourceType: resourceType,
|
|
111
|
+
resourceId: resourceId,
|
|
46
112
|
raw: event,
|
|
47
113
|
uid: uid,
|
|
48
114
|
};
|
|
@@ -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:
|
|
66
|
-
paypal:
|
|
65
|
+
stripe: null,
|
|
66
|
+
paypal: null,
|
|
67
67
|
},
|
|
68
68
|
annually: {
|
|
69
69
|
amount: 49.99,
|
|
70
|
-
stripe:
|
|
71
|
-
paypal:
|
|
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}`);
|