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 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.88",
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;
@@ -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
- uid: uid,
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-subscriptions
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 resource
118
- if (resourceId) {
119
- const existingDoc = await admin.firestore().doc(`payments-subscriptions/${resourceId}`).get();
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-subscriptions/{resourceId}
172
- if (resourceId) {
173
- await admin.firestore().doc(`payments-subscriptions/${resourceId}`).set({
174
- 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,
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-subscriptions/${resourceId}: uid=${uid}, eventType=${eventType}`);
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-one-time
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 resource
209
- if (resourceId) {
210
- const existingDoc = await admin.firestore().doc(`payments-one-time/${resourceId}`).get();
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-one-time/{resourceId}
254
- if (resourceId) {
255
- await admin.firestore().doc(`payments-one-time/${resourceId}`).set({
256
- 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,
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-one-time/${resourceId}: uid=${uid}, eventType=${eventType}`);
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 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',
@@ -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();
@@ -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
- // Append orderId as raw string — Stripe replaces {CHECKOUT_SESSION_ID} at redirect
62
- // time, but only if the braces are NOT URL-encoded
63
- confirmationUrl = `${confirmationUrl.toString()}&orderId={CHECKOUT_SESSION_ID}`;
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
  };
@@ -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,
@@ -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
  },