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 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-subscriptions/{subId}.subscription`:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.87",
3
+ "version": "5.0.89",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -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-subscriptions/{resourceId}
12
- * - one-time → toUnifiedOneTime → payments-one-time/{resourceId}
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.uid;
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
- uid: uid,
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-subscriptions
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-subscriptions/{resourceId}
159
- if (resourceId) {
160
- await admin.firestore().doc(`payments-subscriptions/${resourceId}`).set({
161
- uid: uid,
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-subscriptions/${resourceId}: uid=${uid}, eventType=${eventType}`);
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-one-time
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-one-time/{resourceId}
229
- if (resourceId) {
230
- await admin.firestore().doc(`payments-one-time/${resourceId}`).set({
231
- uid: uid,
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-one-time/${resourceId}: uid=${uid}, eventType=${eventType}`);
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 = assistant.isDevelopment()
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
- ? (assistant.isDevelopment() ? `https://localhost:4000/account` : `${Manager.config.brand.url}/account`)
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',
@@ -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 (unused for Stripe — fetches live)
36
- * @param {object} context - Additional context (e.g., { admin }) — unused for Stripe
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
- if (resourceType === 'subscription') {
43
- return stripe.subscriptions.retrieve(resourceId);
44
- }
43
+ try {
44
+ if (resourceType === 'subscription') {
45
+ return await stripe.subscriptions.retrieve(resourceId);
46
+ }
45
47
 
46
- if (resourceType === 'invoice') {
47
- return stripe.invoices.retrieve(resourceId);
48
- }
48
+ if (resourceType === 'invoice') {
49
+ return await stripe.invoices.retrieve(resourceId);
50
+ }
49
51
 
50
- if (resourceType === 'session') {
51
- return stripe.checkout.sessions.retrieve(resourceId);
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
- throw new Error(`Unknown resource type: ${resourceType}`);
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 Firestore
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 collection = resourceType === 'subscription' ? 'payments-subscriptions' : 'payments-one-time';
33
- const doc = await admin.firestore().doc(`${collection}/${resourceId}`).get();
34
-
35
- if (doc.exists) {
36
- const data = doc.data();
37
- // payments-subscriptions stores the unified subscription inside .subscription
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.config.brand.url}/blog`,
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-subscriptions')
53
- .where('uid', '==', uid)
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/${result.id}`).set({
101
- id: result.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
- uid: uid,
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/${result.id}: uid=${uid}, product=${productId}, type=${productType}, frequency=${frequency}, trial=${trial}`);
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 = config.brand?.url;
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
- const confirmationUrl = new URL('/payment/confirmation', baseUrl);
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
- const cancelUrl = new URL('/payment/checkout', baseUrl);
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.toString(),
109
- cancel_url: cancelUrl.toString(),
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.toString(),
142
- cancel_url: cancelUrl.toString(),
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
  };
@@ -82,7 +82,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
82
82
  processor: processor,
83
83
  status: 'pending',
84
84
  raw: raw,
85
- uid: uid,
85
+ owner: uid,
86
86
  event: {
87
87
  type: eventType,
88
88
  category: category,
@@ -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 = assistant.isDevelopment()
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 = assistant.isDevelopment()
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-subscriptions', 'payments-webhooks', 'payments-intents', 'payments-one-time'];
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('uid', 'in', testUids)
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: 'subscription-doc-created',
81
+ name: 'order-doc-created',
78
82
  async run({ firestore, assert, state }) {
79
- const subDoc = await firestore.get(`payments-subscriptions/${state.subscriptionId}`);
80
-
81
- assert.ok(subDoc, 'Subscription doc should exist');
82
- assert.equal(subDoc.uid, state.uid, 'UID should match');
83
- assert.equal(subDoc.processor, 'test', 'Processor should be test');
84
- assert.equal(subDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
85
- assert.equal(subDoc.subscription.status, 'active', 'Status should be active');
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.intentId}`);
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.uid, state.uid, 'UID should match');
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
  },
@@ -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.id}`);
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 subDocPath = `payments-subscriptions/_test-sub-history-${uid}`;
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(subDocPath, { uid, processor: 'test', status: 'cancelled' });
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.id}`);
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(subDocPath);
187
+ await firestore.delete(orderDocPath);
185
188
  }
186
189
  },
187
190
  },