backend-manager 5.0.87 → 5.0.89
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/README.md +1 -1
- package/package.json +1 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +55 -20
- package/src/manager/functions/core/actions/api/user/oauth2.js +2 -6
- package/src/manager/helpers/user.js +1 -0
- package/src/manager/index.js +8 -0
- package/src/manager/libraries/payment-processors/order-id.js +18 -0
- package/src/manager/libraries/payment-processors/stripe.js +24 -12
- package/src/manager/libraries/payment-processors/test.js +11 -8
- package/src/manager/routes/handler/post/post.js +1 -1
- package/src/manager/routes/payments/intent/post.js +17 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +19 -13
- package/src/manager/routes/payments/intent/processors/test.js +7 -7
- package/src/manager/routes/payments/webhook/post.js +1 -1
- package/src/manager/routes/user/oauth2/_helpers.js +1 -3
- package/src/manager/routes/user/oauth2/post.js +1 -3
- package/src/test/test-accounts.js +2 -2
- 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-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/README.md
CHANGED
|
@@ -886,7 +886,7 @@ BEM includes a built-in payment/subscription system with Stripe integration (ext
|
|
|
886
886
|
|
|
887
887
|
### Unified Subscription Object
|
|
888
888
|
|
|
889
|
-
The same subscription shape is stored in `users/{uid}.subscription` and `payments-
|
|
889
|
+
The same subscription shape is stored in `users/{uid}.subscription` and `payments-orders/{orderId}.subscription`:
|
|
890
890
|
|
|
891
891
|
```javascript
|
|
892
892
|
subscription: {
|
package/package.json
CHANGED
|
@@ -8,8 +8,8 @@ const transitions = require('./transitions/index.js');
|
|
|
8
8
|
* 1. Loads the processor library
|
|
9
9
|
* 2. Fetches the latest resource from the processor API (not the stale webhook payload)
|
|
10
10
|
* 3. Branches on event.category to transform + write:
|
|
11
|
-
* - subscription → toUnifiedSubscription → users/{uid}.subscription + payments-
|
|
12
|
-
* - one-time → toUnifiedOneTime → payments-
|
|
11
|
+
* - subscription → toUnifiedSubscription → users/{uid}.subscription + payments-orders/{orderId}
|
|
12
|
+
* - one-time → toUnifiedOneTime → payments-orders/{orderId}
|
|
13
13
|
* 4. Detects state transitions and dispatches handler files (non-blocking)
|
|
14
14
|
* 5. Marks the webhook as completed
|
|
15
15
|
*/
|
|
@@ -31,7 +31,7 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
33
|
const processor = dataAfter.processor;
|
|
34
|
-
const uid = dataAfter.
|
|
34
|
+
const uid = dataAfter.owner;
|
|
35
35
|
const raw = dataAfter.raw;
|
|
36
36
|
const eventType = dataAfter.event?.type;
|
|
37
37
|
const category = dataAfter.event?.category;
|
|
@@ -68,14 +68,18 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
68
68
|
// Build timestamps
|
|
69
69
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
70
70
|
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
71
|
+
const webhookReceivedUNIX = dataAfter.metadata?.received?.timestampUNIX || nowUNIX;
|
|
72
|
+
|
|
73
|
+
// Extract orderId from resource metadata (set at intent creation)
|
|
74
|
+
const orderId = resource.metadata?.orderId || null;
|
|
71
75
|
|
|
72
76
|
// Branch on category
|
|
73
77
|
let transitionName = null;
|
|
74
78
|
|
|
75
79
|
if (category === 'subscription') {
|
|
76
|
-
transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager });
|
|
80
|
+
transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
|
|
77
81
|
} else if (category === 'one-time') {
|
|
78
|
-
transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager });
|
|
82
|
+
transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
|
|
79
83
|
} else {
|
|
80
84
|
throw new Error(`Unknown event category: ${category}`);
|
|
81
85
|
}
|
|
@@ -83,7 +87,8 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
83
87
|
// Mark webhook as completed (include transition name for auditing/testing)
|
|
84
88
|
await webhookRef.set({
|
|
85
89
|
status: 'completed',
|
|
86
|
-
|
|
90
|
+
owner: uid,
|
|
91
|
+
orderId: orderId,
|
|
87
92
|
transition: transitionName,
|
|
88
93
|
metadata: {
|
|
89
94
|
processed: {
|
|
@@ -110,9 +115,21 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
110
115
|
* 1. Read current user subscription (before state)
|
|
111
116
|
* 2. Transform raw resource → unified subscription (after state)
|
|
112
117
|
* 3. Detect and dispatch transition handlers (non-blocking)
|
|
113
|
-
* 4. Write to user doc + payments-
|
|
118
|
+
* 4. Write to user doc + payments-orders
|
|
114
119
|
*/
|
|
115
|
-
async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager }) {
|
|
120
|
+
async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
|
|
121
|
+
// Staleness check: skip if a newer webhook already wrote to this order
|
|
122
|
+
if (orderId) {
|
|
123
|
+
const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
|
|
124
|
+
if (existingDoc.exists) {
|
|
125
|
+
const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
|
|
126
|
+
if (webhookReceivedUNIX < existingUpdatedUNIX) {
|
|
127
|
+
assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
// Read current user doc BEFORE writing (for transition detection)
|
|
117
134
|
const userDoc = await admin.firestore().doc(`users/${uid}`).get();
|
|
118
135
|
const userData = userDoc.exists ? userDoc.data() : {};
|
|
@@ -155,11 +172,14 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
|
|
|
155
172
|
|
|
156
173
|
assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
|
|
157
174
|
|
|
158
|
-
// Write to payments-
|
|
159
|
-
if (
|
|
160
|
-
await admin.firestore().doc(`payments-
|
|
161
|
-
|
|
175
|
+
// Write to payments-orders/{orderId}
|
|
176
|
+
if (orderId) {
|
|
177
|
+
await admin.firestore().doc(`payments-orders/${orderId}`).set({
|
|
178
|
+
id: orderId,
|
|
179
|
+
type: 'subscription',
|
|
180
|
+
owner: uid,
|
|
162
181
|
processor: processor,
|
|
182
|
+
resourceId: resourceId,
|
|
163
183
|
subscription: unified,
|
|
164
184
|
metadata: {
|
|
165
185
|
created: {
|
|
@@ -179,7 +199,7 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
|
|
|
179
199
|
},
|
|
180
200
|
}, { merge: true });
|
|
181
201
|
|
|
182
|
-
assistant.log(`Updated payments-
|
|
202
|
+
assistant.log(`Updated payments-orders/${orderId}: type=subscription, uid=${uid}, eventType=${eventType}`);
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
return transitionName;
|
|
@@ -189,9 +209,21 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
|
|
|
189
209
|
* Process a one-time payment event
|
|
190
210
|
* 1. Transform raw resource → unified one-time
|
|
191
211
|
* 2. Detect and dispatch transition handlers (non-blocking)
|
|
192
|
-
* 3. Write to payments-
|
|
212
|
+
* 3. Write to payments-orders
|
|
193
213
|
*/
|
|
194
|
-
async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager }) {
|
|
214
|
+
async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
|
|
215
|
+
// Staleness check: skip if a newer webhook already wrote to this order
|
|
216
|
+
if (orderId) {
|
|
217
|
+
const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
|
|
218
|
+
if (existingDoc.exists) {
|
|
219
|
+
const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
|
|
220
|
+
if (webhookReceivedUNIX < existingUpdatedUNIX) {
|
|
221
|
+
assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
195
227
|
const unified = library.toUnifiedOneTime(resource, {
|
|
196
228
|
config: Manager.config,
|
|
197
229
|
eventName: eventType,
|
|
@@ -225,11 +257,14 @@ async function processOneTime({ library, resource, uid, processor, eventType, ev
|
|
|
225
257
|
trackPayment({ category: 'one-time', transitionName, unified, uid, processor, assistant, Manager });
|
|
226
258
|
}
|
|
227
259
|
|
|
228
|
-
// Write to payments-
|
|
229
|
-
if (
|
|
230
|
-
await admin.firestore().doc(`payments-
|
|
231
|
-
|
|
260
|
+
// Write to payments-orders/{orderId}
|
|
261
|
+
if (orderId) {
|
|
262
|
+
await admin.firestore().doc(`payments-orders/${orderId}`).set({
|
|
263
|
+
id: orderId,
|
|
264
|
+
type: 'one-time',
|
|
265
|
+
owner: uid,
|
|
232
266
|
processor: processor,
|
|
267
|
+
resourceId: resourceId,
|
|
233
268
|
payment: unified,
|
|
234
269
|
metadata: {
|
|
235
270
|
created: {
|
|
@@ -249,7 +284,7 @@ async function processOneTime({ library, resource, uid, processor, eventType, ev
|
|
|
249
284
|
},
|
|
250
285
|
}, { merge: true });
|
|
251
286
|
|
|
252
|
-
assistant.log(`Updated payments-
|
|
287
|
+
assistant.log(`Updated payments-orders/${orderId}: type=one-time, uid=${uid}, eventType=${eventType}`);
|
|
253
288
|
}
|
|
254
289
|
|
|
255
290
|
return transitionName;
|
|
@@ -25,14 +25,10 @@ Module.prototype.main = function () {
|
|
|
25
25
|
Api.resolveUser({adminRequired: true})
|
|
26
26
|
.then(async (user) => {
|
|
27
27
|
|
|
28
|
-
self.ultimateJekyllOAuth2Url =
|
|
29
|
-
? `https://localhost:4000/oauth2`
|
|
30
|
-
: `${Manager.config.brand.url}/oauth2`
|
|
28
|
+
self.ultimateJekyllOAuth2Url = `${Manager.project.websiteUrl}/oauth2`
|
|
31
29
|
self.oauth2 = null;
|
|
32
30
|
self.omittedPayloadFields = ['redirect', 'referrer', 'provider', 'state'];
|
|
33
31
|
|
|
34
|
-
// self.ultimateJekyllOAuth2Url = `${Manager.config.brand.url}/oauth2`;
|
|
35
|
-
|
|
36
32
|
// Options
|
|
37
33
|
// payload.data.payload.uid = payload.data.payload.uid;
|
|
38
34
|
payload.data.payload.redirect = typeof payload.data.payload.redirect === 'undefined'
|
|
@@ -40,7 +36,7 @@ Module.prototype.main = function () {
|
|
|
40
36
|
: payload.data.payload.redirect
|
|
41
37
|
|
|
42
38
|
payload.data.payload.referrer = typeof payload.data.payload.referrer === 'undefined'
|
|
43
|
-
?
|
|
39
|
+
? `${Manager.project.websiteUrl}/account`
|
|
44
40
|
: payload.data.payload.referrer
|
|
45
41
|
|
|
46
42
|
payload.data.payload.serverUrl = typeof payload.data.payload.serverUrl === 'undefined'
|
|
@@ -38,6 +38,7 @@ const SCHEMA = {
|
|
|
38
38
|
},
|
|
39
39
|
payment: {
|
|
40
40
|
processor: { type: 'string', default: null, nullable: true },
|
|
41
|
+
orderId: { type: 'string', default: null, nullable: true },
|
|
41
42
|
resourceId: { type: 'string', default: null, nullable: true },
|
|
42
43
|
frequency: { type: 'string', default: null, nullable: true },
|
|
43
44
|
startDate: '$timestamp',
|
package/src/manager/index.js
CHANGED
|
@@ -178,12 +178,20 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
178
178
|
? 'http://localhost:5002'
|
|
179
179
|
: `https://api.${(self.config.brand?.url || '').replace(/^https?:\/\//, '')}`;
|
|
180
180
|
|
|
181
|
+
// Set website URL
|
|
182
|
+
// Development: https://localhost:4000 (local hosting)
|
|
183
|
+
// Production: https://{domain} (from brand.url)
|
|
184
|
+
self.project.websiteUrl = self.assistant.isDevelopment()
|
|
185
|
+
? 'https://localhost:4000'
|
|
186
|
+
: self.config.brand?.url || '';
|
|
187
|
+
|
|
181
188
|
// Set environment
|
|
182
189
|
process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.assistant.meta.environment;
|
|
183
190
|
|
|
184
191
|
// Set BEM env variables
|
|
185
192
|
process.env.BEM_FUNCTIONS_URL = self.project.functionsUrl;
|
|
186
193
|
process.env.BEM_API_URL = self.project.apiUrl;
|
|
194
|
+
process.env.BEM_WEBSITE_URL = self.project.websiteUrl;
|
|
187
195
|
|
|
188
196
|
// Use the working Firebase logger that they disabled for whatever reason
|
|
189
197
|
if (
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a unique order ID in the format XXXX-XXXX-XXXX
|
|
5
|
+
* 12 random digits, grouped in 3 segments of 4
|
|
6
|
+
*
|
|
7
|
+
* @returns {string} e.g. '4637-8821-0473'
|
|
8
|
+
*/
|
|
9
|
+
function generate() {
|
|
10
|
+
const bytes = crypto.randomBytes(6);
|
|
11
|
+
const digits = Array.from(bytes)
|
|
12
|
+
.map(b => (b % 100).toString().padStart(2, '0'))
|
|
13
|
+
.join('');
|
|
14
|
+
|
|
15
|
+
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 12)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { generate };
|
|
@@ -29,29 +29,39 @@ const Stripe = {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Fetch the latest resource from Stripe's API
|
|
32
|
+
* Falls back to the raw webhook payload if the API call fails
|
|
32
33
|
*
|
|
33
34
|
* @param {string} resourceType - 'subscription' | 'invoice' | 'session'
|
|
34
35
|
* @param {string} resourceId - Stripe resource ID
|
|
35
|
-
* @param {object} rawFallback - Fallback data from webhook payload
|
|
36
|
-
* @param {object} context - Additional context (e.g., { admin })
|
|
36
|
+
* @param {object} rawFallback - Fallback data from webhook payload
|
|
37
|
+
* @param {object} context - Additional context (e.g., { admin })
|
|
37
38
|
* @returns {object} Full Stripe resource object
|
|
38
39
|
*/
|
|
39
40
|
async fetchResource(resourceType, resourceId, rawFallback, context) {
|
|
40
41
|
const stripe = this.init();
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
try {
|
|
44
|
+
if (resourceType === 'subscription') {
|
|
45
|
+
return await stripe.subscriptions.retrieve(resourceId);
|
|
46
|
+
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
if (resourceType === 'invoice') {
|
|
49
|
+
return await stripe.invoices.retrieve(resourceId);
|
|
50
|
+
}
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
if (resourceType === 'session') {
|
|
53
|
+
return await stripe.checkout.sessions.retrieve(resourceId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(`Unknown resource type: ${resourceType}`);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// If the API call fails but we have raw webhook data, use it
|
|
59
|
+
if (rawFallback && Object.keys(rawFallback).length > 0) {
|
|
60
|
+
return rawFallback;
|
|
61
|
+
}
|
|
53
62
|
|
|
54
|
-
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
55
65
|
},
|
|
56
66
|
|
|
57
67
|
/**
|
|
@@ -105,6 +115,7 @@ const Stripe = {
|
|
|
105
115
|
cancellation: cancellation,
|
|
106
116
|
payment: {
|
|
107
117
|
processor: 'stripe',
|
|
118
|
+
orderId: rawSubscription.metadata?.orderId || null,
|
|
108
119
|
resourceId: rawSubscription.id || null,
|
|
109
120
|
frequency: frequency,
|
|
110
121
|
startDate: startDate,
|
|
@@ -139,6 +150,7 @@ const Stripe = {
|
|
|
139
150
|
return {
|
|
140
151
|
id: rawResource.id || null,
|
|
141
152
|
processor: 'stripe',
|
|
153
|
+
orderId: rawResource.metadata?.orderId || null,
|
|
142
154
|
status: rawResource.status || 'unknown',
|
|
143
155
|
raw: rawResource,
|
|
144
156
|
metadata: {
|
|
@@ -26,15 +26,18 @@ const Test = {
|
|
|
26
26
|
return rawFallback;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Fallback doesn't match — try to look up the resource from
|
|
29
|
+
// Fallback doesn't match — try to look up the resource from payments-orders
|
|
30
30
|
const admin = context?.admin;
|
|
31
31
|
if (admin && resourceId) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
const snapshot = await admin.firestore()
|
|
33
|
+
.collection('payments-orders')
|
|
34
|
+
.where('resourceId', '==', resourceId)
|
|
35
|
+
.limit(1)
|
|
36
|
+
.get();
|
|
37
|
+
|
|
38
|
+
if (!snapshot.empty) {
|
|
39
|
+
const data = snapshot.docs[0].data();
|
|
40
|
+
// payments-orders stores the unified subscription inside .subscription
|
|
38
41
|
// Reconstruct a Stripe-shaped object from the unified data for toUnifiedSubscription()
|
|
39
42
|
if (resourceType === 'subscription' && data.subscription) {
|
|
40
43
|
return buildStripeSubscriptionFromUnified(data.subscription, resourceId, context?.eventType, context?.config);
|
|
@@ -110,7 +113,7 @@ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, conf
|
|
|
110
113
|
id: resourceId,
|
|
111
114
|
object: 'subscription',
|
|
112
115
|
status: status,
|
|
113
|
-
metadata: {},
|
|
116
|
+
metadata: { orderId: unified.payment?.orderId || null },
|
|
114
117
|
plan: {
|
|
115
118
|
id: priceId,
|
|
116
119
|
interval: INTERVAL_MAP[frequency] || 'month',
|
|
@@ -110,7 +110,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
110
110
|
notification: {
|
|
111
111
|
title: settings.title,
|
|
112
112
|
body: `"${settings.title}" was just published on our blog. It's a great read and we think you'll enjoy the content!`,
|
|
113
|
-
click_action: `${Manager.
|
|
113
|
+
click_action: `${Manager.project.websiteUrl}/blog`,
|
|
114
114
|
icon: Manager.config.brand.images.brandmark,
|
|
115
115
|
}
|
|
116
116
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const powertools = require('node-powertools');
|
|
3
|
+
const OrderId = require('../../../libraries/payment-processors/order-id.js');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* POST /payments/intent
|
|
@@ -49,8 +50,9 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
49
50
|
// Resolve trial eligibility: if requested but user has subscription history, silently downgrade
|
|
50
51
|
if (trial) {
|
|
51
52
|
const historySnapshot = await admin.firestore()
|
|
52
|
-
.collection('payments-
|
|
53
|
-
.where('
|
|
53
|
+
.collection('payments-orders')
|
|
54
|
+
.where('owner', '==', uid)
|
|
55
|
+
.where('type', '==', 'subscription')
|
|
54
56
|
.limit(1)
|
|
55
57
|
.get();
|
|
56
58
|
|
|
@@ -64,6 +66,11 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
64
66
|
trial = false;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Generate order ID
|
|
70
|
+
const orderId = OrderId.generate();
|
|
71
|
+
|
|
72
|
+
assistant.log(`Generated orderId=${orderId}`);
|
|
73
|
+
|
|
67
74
|
// Load the processor module
|
|
68
75
|
let processorModule;
|
|
69
76
|
try {
|
|
@@ -77,6 +84,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
77
84
|
try {
|
|
78
85
|
result = await processorModule.createIntent({
|
|
79
86
|
uid,
|
|
87
|
+
orderId,
|
|
80
88
|
product,
|
|
81
89
|
productId,
|
|
82
90
|
frequency,
|
|
@@ -96,11 +104,12 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
96
104
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
97
105
|
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
98
106
|
|
|
99
|
-
// Save to payments-intents collection
|
|
100
|
-
await admin.firestore().doc(`payments-intents/${
|
|
101
|
-
id:
|
|
107
|
+
// Save to payments-intents collection (keyed by orderId for consistent lookup with payments-orders)
|
|
108
|
+
await admin.firestore().doc(`payments-intents/${orderId}`).set({
|
|
109
|
+
id: orderId,
|
|
110
|
+
intentId: result.id,
|
|
102
111
|
processor: processor,
|
|
103
|
-
|
|
112
|
+
owner: uid,
|
|
104
113
|
status: 'pending',
|
|
105
114
|
productId: productId,
|
|
106
115
|
productType: productType,
|
|
@@ -115,10 +124,11 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
115
124
|
},
|
|
116
125
|
});
|
|
117
126
|
|
|
118
|
-
assistant.log(`Saved payments-intents/${
|
|
127
|
+
assistant.log(`Saved payments-intents/${orderId}: uid=${uid}, product=${productId}, type=${productType}, frequency=${frequency}, trial=${trial}`);
|
|
119
128
|
|
|
120
129
|
return assistant.respond({
|
|
121
130
|
id: result.id,
|
|
131
|
+
orderId: orderId,
|
|
122
132
|
url: result.url,
|
|
123
133
|
});
|
|
124
134
|
};
|
|
@@ -16,7 +16,7 @@ module.exports = {
|
|
|
16
16
|
* @param {object} options.Manager - Manager instance
|
|
17
17
|
* @returns {object} { id, url, raw }
|
|
18
18
|
*/
|
|
19
|
-
async createIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
|
|
19
|
+
async createIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
|
|
20
20
|
// Initialize Stripe SDK
|
|
21
21
|
const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
|
|
22
22
|
const stripe = StripeLib.init();
|
|
@@ -44,13 +44,12 @@ module.exports = {
|
|
|
44
44
|
assistant?.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
|
|
45
45
|
|
|
46
46
|
// Build confirmation redirect URL
|
|
47
|
-
const baseUrl =
|
|
47
|
+
const baseUrl = Manager.project.websiteUrl;
|
|
48
48
|
const amount = productType === 'subscription'
|
|
49
49
|
? (product.prices?.[frequency]?.amount || 0)
|
|
50
50
|
: (product.prices?.once?.amount || 0);
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
confirmationUrl.searchParams.set('orderId', '{CHECKOUT_SESSION_ID}');
|
|
52
|
+
let confirmationUrl = new URL('/payment/confirmation', baseUrl);
|
|
54
53
|
confirmationUrl.searchParams.set('productId', productId);
|
|
55
54
|
confirmationUrl.searchParams.set('productName', product.name || productId);
|
|
56
55
|
confirmationUrl.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
|
|
@@ -58,22 +57,25 @@ module.exports = {
|
|
|
58
57
|
confirmationUrl.searchParams.set('frequency', frequency || 'once');
|
|
59
58
|
confirmationUrl.searchParams.set('paymentMethod', 'stripe');
|
|
60
59
|
confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
|
|
60
|
+
confirmationUrl.searchParams.set('orderId', orderId);
|
|
61
61
|
confirmationUrl.searchParams.set('track', 'true');
|
|
62
|
+
confirmationUrl = confirmationUrl.toString();
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
let cancelUrl = new URL('/payment/checkout', baseUrl);
|
|
64
65
|
cancelUrl.searchParams.set('product', productId);
|
|
65
66
|
if (frequency) {
|
|
66
67
|
cancelUrl.searchParams.set('frequency', frequency);
|
|
67
68
|
}
|
|
68
69
|
cancelUrl.searchParams.set('payment', 'cancelled');
|
|
70
|
+
cancelUrl = cancelUrl.toString();
|
|
69
71
|
|
|
70
72
|
// Build session params based on product type
|
|
71
73
|
let sessionParams;
|
|
72
74
|
|
|
73
75
|
if (productType === 'subscription') {
|
|
74
|
-
sessionParams = buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl });
|
|
76
|
+
sessionParams = buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl });
|
|
75
77
|
} else {
|
|
76
|
-
sessionParams = buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl });
|
|
78
|
+
sessionParams = buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, confirmationUrl, cancelUrl });
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
// Create the checkout session
|
|
@@ -92,7 +94,7 @@ module.exports = {
|
|
|
92
94
|
/**
|
|
93
95
|
* Build Stripe Checkout Session params for a subscription
|
|
94
96
|
*/
|
|
95
|
-
function buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
|
|
97
|
+
function buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
|
|
96
98
|
const sessionParams = {
|
|
97
99
|
mode: 'subscription',
|
|
98
100
|
customer: customer.id,
|
|
@@ -103,12 +105,14 @@ function buildSubscriptionSession({ priceId, customer, uid, productId, frequency
|
|
|
103
105
|
subscription_data: {
|
|
104
106
|
metadata: {
|
|
105
107
|
uid: uid,
|
|
108
|
+
orderId: orderId,
|
|
106
109
|
},
|
|
107
110
|
},
|
|
108
|
-
success_url: confirmationUrl
|
|
109
|
-
cancel_url: cancelUrl
|
|
111
|
+
success_url: confirmationUrl,
|
|
112
|
+
cancel_url: cancelUrl,
|
|
110
113
|
metadata: {
|
|
111
114
|
uid: uid,
|
|
115
|
+
orderId: orderId,
|
|
112
116
|
productId: productId,
|
|
113
117
|
frequency: frequency,
|
|
114
118
|
},
|
|
@@ -125,7 +129,7 @@ function buildSubscriptionSession({ priceId, customer, uid, productId, frequency
|
|
|
125
129
|
/**
|
|
126
130
|
* Build Stripe Checkout Session params for a one-time payment
|
|
127
131
|
*/
|
|
128
|
-
function buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl }) {
|
|
132
|
+
function buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, confirmationUrl, cancelUrl }) {
|
|
129
133
|
return {
|
|
130
134
|
mode: 'payment',
|
|
131
135
|
customer: customer.id,
|
|
@@ -136,12 +140,14 @@ function buildOneTimeSession({ priceId, customer, uid, productId, product, confi
|
|
|
136
140
|
payment_intent_data: {
|
|
137
141
|
metadata: {
|
|
138
142
|
uid: uid,
|
|
143
|
+
orderId: orderId,
|
|
139
144
|
},
|
|
140
145
|
},
|
|
141
|
-
success_url: confirmationUrl
|
|
142
|
-
cancel_url: cancelUrl
|
|
146
|
+
success_url: confirmationUrl,
|
|
147
|
+
cancel_url: cancelUrl,
|
|
143
148
|
metadata: {
|
|
144
149
|
uid: uid,
|
|
150
|
+
orderId: orderId,
|
|
145
151
|
productId: productId,
|
|
146
152
|
},
|
|
147
153
|
};
|
|
@@ -21,7 +21,7 @@ module.exports = {
|
|
|
21
21
|
* @param {object} options.assistant - Assistant instance
|
|
22
22
|
* @returns {object} { id, url, raw }
|
|
23
23
|
*/
|
|
24
|
-
async createIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
|
|
24
|
+
async createIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
|
|
25
25
|
// Guard: test processor is not available in production
|
|
26
26
|
if (assistant.isProduction()) {
|
|
27
27
|
throw new Error('Test processor is not available in production');
|
|
@@ -30,10 +30,10 @@ module.exports = {
|
|
|
30
30
|
const productType = product.type || 'subscription';
|
|
31
31
|
|
|
32
32
|
if (productType === 'subscription') {
|
|
33
|
-
return createSubscriptionIntent({ uid, product, productId, frequency, trial, config, Manager, assistant });
|
|
33
|
+
return createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant });
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
return createOneTimeIntent({ uid, product, productId, config, Manager, assistant });
|
|
36
|
+
return createOneTimeIntent({ uid, orderId, product, productId, config, Manager, assistant });
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
@@ -41,7 +41,7 @@ module.exports = {
|
|
|
41
41
|
* Create a test subscription intent
|
|
42
42
|
* Generates Stripe-shaped subscription + customer.subscription.created event
|
|
43
43
|
*/
|
|
44
|
-
async function createSubscriptionIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
|
|
44
|
+
async function createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
|
|
45
45
|
// Get the price ID for the requested frequency
|
|
46
46
|
const priceId = product.prices?.[frequency]?.stripe;
|
|
47
47
|
if (!priceId) {
|
|
@@ -68,7 +68,7 @@ async function createSubscriptionIntent({ uid, product, productId, frequency, tr
|
|
|
68
68
|
id: subscriptionId,
|
|
69
69
|
object: 'subscription',
|
|
70
70
|
status: trial && product.trial?.days ? 'trialing' : 'active',
|
|
71
|
-
metadata: { uid },
|
|
71
|
+
metadata: { uid, orderId },
|
|
72
72
|
plan: { id: priceId, interval },
|
|
73
73
|
current_period_end: periodEnd,
|
|
74
74
|
current_period_start: now,
|
|
@@ -110,7 +110,7 @@ async function createSubscriptionIntent({ uid, product, productId, frequency, tr
|
|
|
110
110
|
* Create a test one-time payment intent
|
|
111
111
|
* Generates Stripe-shaped checkout session + checkout.session.completed event
|
|
112
112
|
*/
|
|
113
|
-
async function createOneTimeIntent({ uid, product, productId, config, Manager, assistant }) {
|
|
113
|
+
async function createOneTimeIntent({ uid, orderId, product, productId, config, Manager, assistant }) {
|
|
114
114
|
// Get the price ID for one-time purchase
|
|
115
115
|
const priceId = product.prices?.once?.stripe;
|
|
116
116
|
if (!priceId) {
|
|
@@ -129,7 +129,7 @@ async function createOneTimeIntent({ uid, product, productId, config, Manager, a
|
|
|
129
129
|
mode: 'payment',
|
|
130
130
|
status: 'complete',
|
|
131
131
|
payment_status: 'paid',
|
|
132
|
-
metadata: { uid, productId },
|
|
132
|
+
metadata: { uid, orderId, productId },
|
|
133
133
|
amount_total: Math.round((product.prices?.once?.amount || 0) * 100),
|
|
134
134
|
currency: 'usd',
|
|
135
135
|
};
|
|
@@ -43,9 +43,7 @@ async function buildContext({ assistant, Manager, user, settings, libraries, req
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Build redirect URI
|
|
46
|
-
const redirectUri =
|
|
47
|
-
? 'https://localhost:4000/oauth2'
|
|
48
|
-
: `${Manager.config.brand.url}/oauth2`;
|
|
46
|
+
const redirectUri = `${Manager.project.websiteUrl}/oauth2`;
|
|
49
47
|
|
|
50
48
|
// If provider not required (e.g., tokenize gets it from encrypted state), skip loading
|
|
51
49
|
if (!requireProvider) {
|
|
@@ -52,9 +52,7 @@ async function processTokenize({ assistant, Manager, admin, settings }) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Build redirect URI
|
|
55
|
-
const redirectUri =
|
|
56
|
-
? 'https://localhost:4000/oauth2'
|
|
57
|
-
: `${Manager.config.brand.url}/oauth2`;
|
|
55
|
+
const redirectUri = `${Manager.project.websiteUrl}/oauth2`;
|
|
58
56
|
|
|
59
57
|
// Decrypt and validate state
|
|
60
58
|
let stateData;
|
|
@@ -363,13 +363,13 @@ async function deleteTestUsers(admin) {
|
|
|
363
363
|
|
|
364
364
|
// Clean up payment-related collections for test accounts
|
|
365
365
|
const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
|
|
366
|
-
const paymentCollections = ['payments-
|
|
366
|
+
const paymentCollections = ['payments-orders', 'payments-webhooks', 'payments-intents'];
|
|
367
367
|
|
|
368
368
|
await Promise.all(
|
|
369
369
|
paymentCollections.map(async (collection) => {
|
|
370
370
|
try {
|
|
371
371
|
const snapshot = await admin.firestore().collection(collection)
|
|
372
|
-
.where('
|
|
372
|
+
.where('owner', 'in', testUids)
|
|
373
373
|
.get();
|
|
374
374
|
|
|
375
375
|
await Promise.all(
|
|
@@ -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
|
},
|
|
@@ -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
|
},
|
|
@@ -43,8 +43,10 @@ module.exports = {
|
|
|
43
43
|
|
|
44
44
|
assert.isSuccess(response, 'Intent should succeed');
|
|
45
45
|
assert.ok(response.data.id, 'Should return intent ID');
|
|
46
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
46
47
|
|
|
47
48
|
state.intentId = response.data.id;
|
|
49
|
+
state.orderId = response.data.orderId;
|
|
48
50
|
|
|
49
51
|
// Derive webhook event ID from intent ID (same timestamp)
|
|
50
52
|
state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
|
|
@@ -65,6 +67,7 @@ module.exports = {
|
|
|
65
67
|
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
66
68
|
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
67
69
|
assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should be claimed');
|
|
70
|
+
assert.equal(userDoc.subscription.payment.orderId, state.orderId, 'Order ID should match intent');
|
|
68
71
|
|
|
69
72
|
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
70
73
|
|
|
@@ -72,6 +75,7 @@ module.exports = {
|
|
|
72
75
|
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
73
76
|
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
74
77
|
assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription (trial detected inside handler)');
|
|
78
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
|
|
75
79
|
},
|
|
76
80
|
},
|
|
77
81
|
|
|
@@ -42,9 +42,12 @@ module.exports = {
|
|
|
42
42
|
|
|
43
43
|
assert.isSuccess(response, 'Intent should succeed');
|
|
44
44
|
assert.ok(response.data.id, 'Should return intent ID');
|
|
45
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
46
|
+
assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
|
|
45
47
|
assert.ok(response.data.url, 'Should return URL');
|
|
46
48
|
|
|
47
49
|
state.intentId = response.data.id;
|
|
50
|
+
state.orderId = response.data.orderId;
|
|
48
51
|
|
|
49
52
|
// Derive webhook event ID from intent ID (same timestamp)
|
|
50
53
|
state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
|
|
@@ -65,6 +68,7 @@ module.exports = {
|
|
|
65
68
|
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
66
69
|
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
67
70
|
assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
|
|
71
|
+
assert.equal(userDoc.subscription.payment.orderId, state.orderId, 'Order ID should match intent');
|
|
68
72
|
assert.ok(userDoc.subscription.payment.resourceId, 'Resource ID should be set');
|
|
69
73
|
assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
|
|
70
74
|
assert.equal(userDoc.subscription.cancellation.pending, false, 'Should not be pending cancellation');
|
|
@@ -74,15 +78,18 @@ module.exports = {
|
|
|
74
78
|
},
|
|
75
79
|
|
|
76
80
|
{
|
|
77
|
-
name: '
|
|
81
|
+
name: 'order-doc-created',
|
|
78
82
|
async run({ firestore, assert, state }) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
assert.ok(
|
|
82
|
-
assert.equal(
|
|
83
|
-
assert.equal(
|
|
84
|
-
assert.equal(
|
|
85
|
-
assert.equal(
|
|
83
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
84
|
+
|
|
85
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
86
|
+
assert.equal(orderDoc.id, state.orderId, 'ID should match orderId');
|
|
87
|
+
assert.equal(orderDoc.type, 'subscription', 'Type should be subscription');
|
|
88
|
+
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
89
|
+
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
90
|
+
assert.equal(orderDoc.resourceId, state.subscriptionId, 'Resource ID should match');
|
|
91
|
+
assert.equal(orderDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
92
|
+
assert.equal(orderDoc.subscription.status, 'active', 'Status should be active');
|
|
86
93
|
},
|
|
87
94
|
},
|
|
88
95
|
|
|
@@ -97,16 +104,19 @@ module.exports = {
|
|
|
97
104
|
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
98
105
|
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
99
106
|
assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription');
|
|
107
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
|
|
100
108
|
},
|
|
101
109
|
},
|
|
102
110
|
|
|
103
111
|
{
|
|
104
112
|
name: 'intent-doc-created',
|
|
105
113
|
async run({ firestore, assert, state }) {
|
|
106
|
-
const intentDoc = await firestore.get(`payments-intents/${state.
|
|
114
|
+
const intentDoc = await firestore.get(`payments-intents/${state.orderId}`);
|
|
107
115
|
|
|
108
116
|
assert.ok(intentDoc, 'Intent doc should exist');
|
|
109
|
-
assert.equal(intentDoc.
|
|
117
|
+
assert.equal(intentDoc.id, state.orderId, 'ID should match orderId');
|
|
118
|
+
assert.equal(intentDoc.intentId, state.intentId, 'Intent ID should match processor session ID');
|
|
119
|
+
assert.equal(intentDoc.owner, state.uid, 'Owner should match');
|
|
110
120
|
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
111
121
|
assert.equal(intentDoc.status, 'pending', 'Intent status should be pending');
|
|
112
122
|
assert.equal(intentDoc.productId, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
@@ -365,6 +365,22 @@ module.exports = {
|
|
|
365
365
|
},
|
|
366
366
|
},
|
|
367
367
|
|
|
368
|
+
{
|
|
369
|
+
name: 'payment-order-id-from-metadata',
|
|
370
|
+
async run({ assert }) {
|
|
371
|
+
const result = toUnifiedSubscription({ metadata: { orderId: '1234-5678-9012' } });
|
|
372
|
+
assert.equal(result.payment.orderId, '1234-5678-9012', 'orderId should come from metadata');
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
{
|
|
377
|
+
name: 'payment-order-id-null-when-missing',
|
|
378
|
+
async run({ assert }) {
|
|
379
|
+
const result = toUnifiedSubscription({});
|
|
380
|
+
assert.equal(result.payment.orderId, null, 'Missing metadata → null orderId');
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
|
|
368
384
|
{
|
|
369
385
|
name: 'payment-event-metadata-passed-through',
|
|
370
386
|
async run({ assert }) {
|
|
@@ -432,6 +448,7 @@ module.exports = {
|
|
|
432
448
|
assert.equal(result.trial.claimed, false, 'Empty → trial not claimed');
|
|
433
449
|
assert.equal(result.cancellation.pending, false, 'Empty → not pending');
|
|
434
450
|
assert.equal(result.payment.processor, 'stripe', 'Empty → still stripe');
|
|
451
|
+
assert.equal(result.payment.orderId, null, 'Empty → null orderId');
|
|
435
452
|
assert.equal(result.payment.resourceId, null, 'Empty → null resourceId');
|
|
436
453
|
assert.equal(result.payment.frequency, null, 'Empty → null frequency');
|
|
437
454
|
},
|
package/test/helpers/user.js
CHANGED
|
@@ -393,6 +393,7 @@ module.exports = {
|
|
|
393
393
|
});
|
|
394
394
|
|
|
395
395
|
assert.equal(user.subscription.payment.processor, 'stripe', 'processor preserved');
|
|
396
|
+
assert.equal(user.subscription.payment.orderId, null, 'missing orderId defaults to null');
|
|
396
397
|
assert.equal(user.subscription.payment.resourceId, 'sub_123', 'resourceId preserved');
|
|
397
398
|
assert.equal(user.subscription.payment.frequency, null, 'missing frequency defaults to null');
|
|
398
399
|
assert.ok(user.subscription.payment.startDate.timestamp, 'missing startDate gets default');
|
|
@@ -126,11 +126,14 @@ module.exports = {
|
|
|
126
126
|
|
|
127
127
|
assert.isSuccess(response, 'Should succeed with test processor');
|
|
128
128
|
assert.ok(response.data.id, 'Should return intent ID');
|
|
129
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
130
|
+
assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
|
|
129
131
|
assert.ok(response.data.url, 'Should return URL');
|
|
130
132
|
|
|
131
|
-
// Verify intent doc was saved
|
|
132
|
-
const intentDoc = await firestore.get(`payments-intents/${response.data.
|
|
133
|
+
// Verify intent doc was saved (keyed by orderId)
|
|
134
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
|
|
133
135
|
assert.ok(intentDoc, 'Intent doc should exist');
|
|
136
|
+
assert.equal(intentDoc.intentId, response.data.id, 'Intent ID should match response');
|
|
134
137
|
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
135
138
|
assert.equal(intentDoc.productId, paidProduct.id, 'Product should match');
|
|
136
139
|
|
|
@@ -151,10 +154,10 @@ module.exports = {
|
|
|
151
154
|
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
152
155
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
153
156
|
const uid = accounts.basic.uid;
|
|
154
|
-
const
|
|
157
|
+
const orderDocPath = `payments-orders/_test-order-history-${uid}`;
|
|
155
158
|
|
|
156
159
|
// Create fake subscription history so user is ineligible for trial
|
|
157
|
-
await firestore.set(
|
|
160
|
+
await firestore.set(orderDocPath, { owner: uid, type: 'subscription', processor: 'test', status: 'cancelled' });
|
|
158
161
|
|
|
159
162
|
try {
|
|
160
163
|
const response = await http.as('basic').post('payments/intent', {
|
|
@@ -167,8 +170,8 @@ module.exports = {
|
|
|
167
170
|
// Should succeed (not reject with 400) — trial silently downgraded
|
|
168
171
|
assert.isSuccess(response, 'Should not reject — trial silently downgraded');
|
|
169
172
|
|
|
170
|
-
// Verify intent saved with trial=false
|
|
171
|
-
const intentDoc = await firestore.get(`payments-intents/${response.data.
|
|
173
|
+
// Verify intent saved with trial=false (keyed by orderId)
|
|
174
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
|
|
172
175
|
assert.equal(intentDoc.trial, false, 'Trial should be false (downgraded)');
|
|
173
176
|
|
|
174
177
|
// Clean up: wait for auto-webhook, restore basic user
|
|
@@ -181,7 +184,7 @@ module.exports = {
|
|
|
181
184
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
182
185
|
}, { merge: true });
|
|
183
186
|
} finally {
|
|
184
|
-
await firestore.delete(
|
|
187
|
+
await firestore.delete(orderDocPath);
|
|
185
188
|
}
|
|
186
189
|
},
|
|
187
190
|
},
|