backend-manager 5.0.88 → 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 +36 -26
- package/src/manager/helpers/user.js +1 -0
- package/src/manager/libraries/payment-processors/order-id.js +18 -0
- package/src/manager/libraries/payment-processors/stripe.js +2 -0
- package/src/manager/libraries/payment-processors/test.js +11 -8
- package/src/manager/routes/payments/intent/post.js +17 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +11 -8
- package/src/manager/routes/payments/intent/processors/test.js +7 -7
- package/src/manager/routes/payments/webhook/post.js +1 -1
- 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;
|
|
@@ -70,13 +70,16 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
70
70
|
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
71
71
|
const webhookReceivedUNIX = dataAfter.metadata?.received?.timestampUNIX || nowUNIX;
|
|
72
72
|
|
|
73
|
+
// Extract orderId from resource metadata (set at intent creation)
|
|
74
|
+
const orderId = resource.metadata?.orderId || null;
|
|
75
|
+
|
|
73
76
|
// Branch on category
|
|
74
77
|
let transitionName = null;
|
|
75
78
|
|
|
76
79
|
if (category === 'subscription') {
|
|
77
|
-
transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
|
|
80
|
+
transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
|
|
78
81
|
} else if (category === 'one-time') {
|
|
79
|
-
transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
|
|
82
|
+
transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
|
|
80
83
|
} else {
|
|
81
84
|
throw new Error(`Unknown event category: ${category}`);
|
|
82
85
|
}
|
|
@@ -84,7 +87,8 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
84
87
|
// Mark webhook as completed (include transition name for auditing/testing)
|
|
85
88
|
await webhookRef.set({
|
|
86
89
|
status: 'completed',
|
|
87
|
-
|
|
90
|
+
owner: uid,
|
|
91
|
+
orderId: orderId,
|
|
88
92
|
transition: transitionName,
|
|
89
93
|
metadata: {
|
|
90
94
|
processed: {
|
|
@@ -111,12 +115,12 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
111
115
|
* 1. Read current user subscription (before state)
|
|
112
116
|
* 2. Transform raw resource → unified subscription (after state)
|
|
113
117
|
* 3. Detect and dispatch transition handlers (non-blocking)
|
|
114
|
-
* 4. Write to user doc + payments-
|
|
118
|
+
* 4. Write to user doc + payments-orders
|
|
115
119
|
*/
|
|
116
|
-
async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
|
|
117
|
-
// Staleness check: skip if a newer webhook already wrote to this
|
|
118
|
-
if (
|
|
119
|
-
const existingDoc = await admin.firestore().doc(`payments-
|
|
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();
|
|
120
124
|
if (existingDoc.exists) {
|
|
121
125
|
const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
|
|
122
126
|
if (webhookReceivedUNIX < existingUpdatedUNIX) {
|
|
@@ -168,11 +172,14 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
|
|
|
168
172
|
|
|
169
173
|
assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
|
|
170
174
|
|
|
171
|
-
// Write to payments-
|
|
172
|
-
if (
|
|
173
|
-
await admin.firestore().doc(`payments-
|
|
174
|
-
|
|
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,
|
|
175
181
|
processor: processor,
|
|
182
|
+
resourceId: resourceId,
|
|
176
183
|
subscription: unified,
|
|
177
184
|
metadata: {
|
|
178
185
|
created: {
|
|
@@ -192,7 +199,7 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
|
|
|
192
199
|
},
|
|
193
200
|
}, { merge: true });
|
|
194
201
|
|
|
195
|
-
assistant.log(`Updated payments-
|
|
202
|
+
assistant.log(`Updated payments-orders/${orderId}: type=subscription, uid=${uid}, eventType=${eventType}`);
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
return transitionName;
|
|
@@ -202,12 +209,12 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
|
|
|
202
209
|
* Process a one-time payment event
|
|
203
210
|
* 1. Transform raw resource → unified one-time
|
|
204
211
|
* 2. Detect and dispatch transition handlers (non-blocking)
|
|
205
|
-
* 3. Write to payments-
|
|
212
|
+
* 3. Write to payments-orders
|
|
206
213
|
*/
|
|
207
|
-
async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
|
|
208
|
-
// Staleness check: skip if a newer webhook already wrote to this
|
|
209
|
-
if (
|
|
210
|
-
const existingDoc = await admin.firestore().doc(`payments-
|
|
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();
|
|
211
218
|
if (existingDoc.exists) {
|
|
212
219
|
const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
|
|
213
220
|
if (webhookReceivedUNIX < existingUpdatedUNIX) {
|
|
@@ -250,11 +257,14 @@ async function processOneTime({ library, resource, uid, processor, eventType, ev
|
|
|
250
257
|
trackPayment({ category: 'one-time', transitionName, unified, uid, processor, assistant, Manager });
|
|
251
258
|
}
|
|
252
259
|
|
|
253
|
-
// Write to payments-
|
|
254
|
-
if (
|
|
255
|
-
await admin.firestore().doc(`payments-
|
|
256
|
-
|
|
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,
|
|
257
266
|
processor: processor,
|
|
267
|
+
resourceId: resourceId,
|
|
258
268
|
payment: unified,
|
|
259
269
|
metadata: {
|
|
260
270
|
created: {
|
|
@@ -274,7 +284,7 @@ async function processOneTime({ library, resource, uid, processor, eventType, ev
|
|
|
274
284
|
},
|
|
275
285
|
}, { merge: true });
|
|
276
286
|
|
|
277
|
-
assistant.log(`Updated payments-
|
|
287
|
+
assistant.log(`Updated payments-orders/${orderId}: type=one-time, uid=${uid}, eventType=${eventType}`);
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
return transitionName;
|
|
@@ -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',
|
|
@@ -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 };
|
|
@@ -115,6 +115,7 @@ const Stripe = {
|
|
|
115
115
|
cancellation: cancellation,
|
|
116
116
|
payment: {
|
|
117
117
|
processor: 'stripe',
|
|
118
|
+
orderId: rawSubscription.metadata?.orderId || null,
|
|
118
119
|
resourceId: rawSubscription.id || null,
|
|
119
120
|
frequency: frequency,
|
|
120
121
|
startDate: startDate,
|
|
@@ -149,6 +150,7 @@ const Stripe = {
|
|
|
149
150
|
return {
|
|
150
151
|
id: rawResource.id || null,
|
|
151
152
|
processor: 'stripe',
|
|
153
|
+
orderId: rawResource.metadata?.orderId || null,
|
|
152
154
|
status: rawResource.status || 'unknown',
|
|
153
155
|
raw: rawResource,
|
|
154
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',
|
|
@@ -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();
|
|
@@ -57,10 +57,9 @@ module.exports = {
|
|
|
57
57
|
confirmationUrl.searchParams.set('frequency', frequency || 'once');
|
|
58
58
|
confirmationUrl.searchParams.set('paymentMethod', 'stripe');
|
|
59
59
|
confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
|
|
60
|
+
confirmationUrl.searchParams.set('orderId', orderId);
|
|
60
61
|
confirmationUrl.searchParams.set('track', 'true');
|
|
61
|
-
|
|
62
|
-
// time, but only if the braces are NOT URL-encoded
|
|
63
|
-
confirmationUrl = `${confirmationUrl.toString()}&orderId={CHECKOUT_SESSION_ID}`;
|
|
62
|
+
confirmationUrl = confirmationUrl.toString();
|
|
64
63
|
|
|
65
64
|
let cancelUrl = new URL('/payment/checkout', baseUrl);
|
|
66
65
|
cancelUrl.searchParams.set('product', productId);
|
|
@@ -74,9 +73,9 @@ module.exports = {
|
|
|
74
73
|
let sessionParams;
|
|
75
74
|
|
|
76
75
|
if (productType === 'subscription') {
|
|
77
|
-
sessionParams = buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl });
|
|
76
|
+
sessionParams = buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl });
|
|
78
77
|
} else {
|
|
79
|
-
sessionParams = buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl });
|
|
78
|
+
sessionParams = buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, confirmationUrl, cancelUrl });
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
// Create the checkout session
|
|
@@ -95,7 +94,7 @@ module.exports = {
|
|
|
95
94
|
/**
|
|
96
95
|
* Build Stripe Checkout Session params for a subscription
|
|
97
96
|
*/
|
|
98
|
-
function buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
|
|
97
|
+
function buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
|
|
99
98
|
const sessionParams = {
|
|
100
99
|
mode: 'subscription',
|
|
101
100
|
customer: customer.id,
|
|
@@ -106,12 +105,14 @@ function buildSubscriptionSession({ priceId, customer, uid, productId, frequency
|
|
|
106
105
|
subscription_data: {
|
|
107
106
|
metadata: {
|
|
108
107
|
uid: uid,
|
|
108
|
+
orderId: orderId,
|
|
109
109
|
},
|
|
110
110
|
},
|
|
111
111
|
success_url: confirmationUrl,
|
|
112
112
|
cancel_url: cancelUrl,
|
|
113
113
|
metadata: {
|
|
114
114
|
uid: uid,
|
|
115
|
+
orderId: orderId,
|
|
115
116
|
productId: productId,
|
|
116
117
|
frequency: frequency,
|
|
117
118
|
},
|
|
@@ -128,7 +129,7 @@ function buildSubscriptionSession({ priceId, customer, uid, productId, frequency
|
|
|
128
129
|
/**
|
|
129
130
|
* Build Stripe Checkout Session params for a one-time payment
|
|
130
131
|
*/
|
|
131
|
-
function buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl }) {
|
|
132
|
+
function buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, confirmationUrl, cancelUrl }) {
|
|
132
133
|
return {
|
|
133
134
|
mode: 'payment',
|
|
134
135
|
customer: customer.id,
|
|
@@ -139,12 +140,14 @@ function buildOneTimeSession({ priceId, customer, uid, productId, product, confi
|
|
|
139
140
|
payment_intent_data: {
|
|
140
141
|
metadata: {
|
|
141
142
|
uid: uid,
|
|
143
|
+
orderId: orderId,
|
|
142
144
|
},
|
|
143
145
|
},
|
|
144
146
|
success_url: confirmationUrl,
|
|
145
147
|
cancel_url: cancelUrl,
|
|
146
148
|
metadata: {
|
|
147
149
|
uid: uid,
|
|
150
|
+
orderId: orderId,
|
|
148
151
|
productId: productId,
|
|
149
152
|
},
|
|
150
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
|
};
|
|
@@ -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
|
},
|