backend-manager 5.0.88 → 5.0.91

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.
Files changed (39) hide show
  1. package/CLAUDE.md +133 -2
  2. package/README.md +1 -1
  3. package/package.json +5 -3
  4. package/src/cli/index.js +11 -0
  5. package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +75 -315
  7. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  9. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
  16. package/src/manager/helpers/user.js +2 -0
  17. package/src/manager/libraries/payment-processors/order-id.js +18 -0
  18. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  19. package/src/manager/libraries/payment-processors/stripe.js +88 -47
  20. package/src/manager/libraries/payment-processors/test.js +14 -11
  21. package/src/manager/routes/payments/intent/post.js +61 -7
  22. package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
  23. package/src/manager/routes/payments/intent/processors/test.js +18 -22
  24. package/src/manager/routes/payments/webhook/post.js +1 -1
  25. package/src/test/runner.js +11 -0
  26. package/src/test/test-accounts.js +20 -2
  27. package/templates/backend-manager-config.json +31 -12
  28. package/test/events/payments/journey-payments-cancel.js +2 -0
  29. package/test/events/payments/journey-payments-failure.js +2 -0
  30. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  31. package/test/events/payments/journey-payments-one-time.js +128 -0
  32. package/test/events/payments/journey-payments-plan-change.js +126 -0
  33. package/test/events/payments/journey-payments-suspend.js +2 -0
  34. package/test/events/payments/journey-payments-trial.js +4 -0
  35. package/test/events/payments/journey-payments-upgrade.js +20 -10
  36. package/test/helpers/stripe-to-unified.js +17 -0
  37. package/test/helpers/user.js +1 -0
  38. package/test/routes/payments/intent.js +10 -7
  39. /package/bin/{bem → backend-manager} +0 -0
@@ -1,15 +1,26 @@
1
1
  /**
2
2
  * Transition: payment-failed
3
3
  * Triggered when a subscription payment fails (active → suspended)
4
- *
5
- * Use cases:
6
- * - Send payment failure notification email
7
- * - Include link to update payment method
8
- * - Fire analytics event for churn risk
9
4
  */
10
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
5
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
6
+
7
+ module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
11
8
  assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product.id}, previousStatus=${before?.status}`);
12
9
 
13
- // TODO: Send payment failure email with update-payment link
14
- // TODO: Fire analytics event
10
+ sendOrderEmail({
11
+ template: 'd-e56af0ac62364bfb9e50af02854e2cd3',
12
+ subject: 'Your payment failed',
13
+ categories: ['order/payment-failed'],
14
+ uid,
15
+ assistant,
16
+ Manager,
17
+ data: {
18
+ order: {
19
+ ...order,
20
+ _computed: {
21
+ date: formatDate(new Date().toISOString()),
22
+ },
23
+ },
24
+ },
25
+ });
15
26
  };
@@ -1,14 +1,26 @@
1
1
  /**
2
2
  * Transition: payment-recovered
3
3
  * Triggered when a suspended subscription is recovered (suspended → active)
4
- *
5
- * Use cases:
6
- * - Send payment recovered confirmation email
7
- * - Fire analytics event for recovered subscriber
8
4
  */
9
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
5
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
6
+
7
+ module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
10
8
  assistant.log(`Transition [subscription/payment-recovered]: uid=${uid}, product=${after.product.id}`);
11
9
 
12
- // TODO: Send payment recovered email
13
- // TODO: Fire analytics event
10
+ sendOrderEmail({
11
+ template: 'd-d6dbd17a260a4755b34a852ba09c2454',
12
+ subject: 'Your payment was successful',
13
+ categories: ['order/payment-recovered'],
14
+ uid,
15
+ assistant,
16
+ Manager,
17
+ data: {
18
+ order: {
19
+ ...order,
20
+ _computed: {
21
+ date: formatDate(new Date().toISOString()),
22
+ },
23
+ },
24
+ },
25
+ });
14
26
  };
@@ -1,16 +1,35 @@
1
1
  /**
2
2
  * Transition: plan-changed
3
3
  * Triggered when a user upgrades or downgrades their plan (product A → product B, both active + paid)
4
- *
5
- * Use cases:
6
- * - Send plan change confirmation email
7
- * - Include new plan details and what changed
8
- * - Fire analytics event for upgrade/downgrade
9
4
  */
10
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
5
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
6
+
7
+ module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
11
8
  const direction = after.product.id > before.product.id ? 'upgrade' : 'downgrade';
12
9
  assistant.log(`Transition [subscription/plan-changed]: uid=${uid}, ${before.product.id} → ${after.product.id} (${direction})`);
13
10
 
14
- // TODO: Send plan change email with new plan details
15
- // TODO: Fire analytics event
11
+ sendOrderEmail({
12
+ template: 'd-399086311bbb48b4b77bc90b20fb9d0a',
13
+ subject: 'Your subscription plan has been updated',
14
+ categories: ['order/plan-changed'],
15
+ uid,
16
+ assistant,
17
+ Manager,
18
+ data: {
19
+ order: {
20
+ ...order,
21
+ // Inject previous plan info into the unified object for the template
22
+ unified: {
23
+ ...order.unified,
24
+ previous: {
25
+ product: before.product,
26
+ price: before.payment?.price || 0,
27
+ },
28
+ },
29
+ _computed: {
30
+ date: formatDate(new Date().toISOString()),
31
+ },
32
+ },
33
+ },
34
+ });
16
35
  };
@@ -1,16 +1,32 @@
1
1
  /**
2
2
  * Transition: subscription-cancelled
3
3
  * Triggered when a subscription is fully cancelled (any non-cancelled → cancelled)
4
- *
5
- * Use cases:
6
- * - Send final cancellation email
7
- * - Include reactivation link or win-back offer
8
- * - Fire analytics event for churned subscriber
9
- * - Clean up any subscription-specific resources
10
4
  */
11
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
5
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
6
+
7
+ module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
12
8
  assistant.log(`Transition [subscription/subscription-cancelled]: uid=${uid}, previousProduct=${before?.product?.id}, previousStatus=${before?.status}`);
13
9
 
14
- // TODO: Send cancellation email with reactivation link
15
- // TODO: Fire analytics event
10
+ // Check if subscription has a future expiry (e.g., cancelled at period end)
11
+ const hasFutureExpiry = after.expires?.timestamp && new Date(after.expires.timestamp) > new Date();
12
+
13
+ sendOrderEmail({
14
+ template: 'd-39041132e6b24e5ebf0e95bce2d94dba',
15
+ subject: 'Your subscription has been cancelled',
16
+ categories: ['order/cancelled'],
17
+ uid,
18
+ assistant,
19
+ Manager,
20
+ data: {
21
+ order: {
22
+ ...order,
23
+ _computed: {
24
+ date: formatDate(new Date().toISOString()),
25
+ ...(hasFutureExpiry && {
26
+ expiresDate: formatDate(after.expires.timestamp),
27
+ }),
28
+ },
29
+ },
30
+ },
31
+ });
16
32
  };
@@ -38,8 +38,10 @@ 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 },
44
+ price: { type: 'number', default: 0 },
43
45
  startDate: '$timestamp',
44
46
  updatedBy: {
45
47
  event: {
@@ -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 };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve the Stripe price ID from a product config object
3
+ *
4
+ * @param {object} product - Product object from config (must have .prices)
5
+ * @param {string} productType - 'subscription' or 'one-time'
6
+ * @param {string} frequency - 'monthly', 'annually', etc. (subscriptions) — ignored for one-time
7
+ * @returns {string} Stripe price ID
8
+ * @throws {Error} If no price ID found
9
+ */
10
+ module.exports = function resolvePriceId(product, productType, frequency) {
11
+ const key = productType === 'subscription' ? frequency : 'once';
12
+ const priceId = product.prices?.[key]?.stripe;
13
+
14
+ if (!priceId) {
15
+ throw new Error(`No Stripe price found for ${product.id}/${key}`);
16
+ }
17
+
18
+ return priceId;
19
+ };
@@ -3,6 +3,13 @@ const powertools = require('node-powertools');
3
3
  // Lazy singleton Stripe SDK instance
4
4
  let stripeInstance = null;
5
5
 
6
+ // Epoch zero timestamps (used as default/empty dates)
7
+ const EPOCH_ZERO = powertools.timestamp(new Date(0), { output: 'string' });
8
+ const EPOCH_ZERO_UNIX = powertools.timestamp(EPOCH_ZERO, { output: 'unix' });
9
+
10
+ // Stripe interval → unified frequency map
11
+ const INTERVAL_TO_FREQUENCY = { year: 'annually', month: 'monthly', week: 'weekly', day: 'daily' };
12
+
6
13
  /**
7
14
  * Stripe shared library
8
15
  * Provides SDK initialization, resource fetching, and unified transformations
@@ -79,9 +86,6 @@ const Stripe = {
79
86
  options = options || {};
80
87
  const config = options.config || {};
81
88
 
82
- const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
83
- const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
84
-
85
89
  // Resolve status
86
90
  const status = resolveStatus(rawSubscription);
87
91
 
@@ -98,10 +102,13 @@ const Stripe = {
98
102
  const product = resolveProduct(rawSubscription, config);
99
103
 
100
104
  // Resolve expiration
101
- const expires = resolveExpires(rawSubscription, oldDate, oldDateUNIX);
105
+ const expires = resolveExpires(rawSubscription);
102
106
 
103
107
  // Resolve start date
104
- const startDate = resolveStartDate(rawSubscription, oldDate, oldDateUNIX);
108
+ const startDate = resolveStartDate(rawSubscription);
109
+
110
+ // Resolve price from config
111
+ const price = resolvePrice(product.id, frequency, config);
105
112
 
106
113
  // Build the unified subscription object
107
114
  const now = powertools.timestamp(new Date(), { output: 'string' });
@@ -115,8 +122,10 @@ const Stripe = {
115
122
  cancellation: cancellation,
116
123
  payment: {
117
124
  processor: 'stripe',
125
+ orderId: rawSubscription.metadata?.orderId || null,
118
126
  resourceId: rawSubscription.id || null,
119
127
  frequency: frequency,
128
+ price: price,
120
129
  startDate: startDate,
121
130
  updatedBy: {
122
131
  event: {
@@ -134,7 +143,7 @@ const Stripe = {
134
143
 
135
144
  /**
136
145
  * Transform a raw Stripe one-time payment resource into a unified shape
137
- * Stub for now will be fully implemented when one-time purchases are built out
146
+ * Mirrors subscription structure: { product, status, payment: { ... } }
138
147
  *
139
148
  * @param {object} rawResource - Raw Stripe resource (session, invoice, etc.)
140
149
  * @param {object} options
@@ -142,19 +151,33 @@ const Stripe = {
142
151
  */
143
152
  toUnifiedOneTime(rawResource, options) {
144
153
  options = options || {};
154
+ const config = options.config || {};
145
155
 
146
156
  const now = powertools.timestamp(new Date(), { output: 'string' });
147
157
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
148
158
 
159
+ // Resolve product + price from config
160
+ const productId = rawResource.metadata?.productId;
161
+ const product = resolveProductOneTime(productId, config);
162
+ const price = resolvePrice(productId, 'once', config);
163
+
149
164
  return {
150
- id: rawResource.id || null,
151
- processor: 'stripe',
165
+ product: product,
152
166
  status: rawResource.status || 'unknown',
153
- raw: rawResource,
154
- metadata: {
155
- created: {
156
- timestamp: now,
157
- timestampUNIX: nowUNIX,
167
+ payment: {
168
+ processor: 'stripe',
169
+ orderId: rawResource.metadata?.orderId || null,
170
+ resourceId: rawResource.id || null,
171
+ price: price,
172
+ updatedBy: {
173
+ event: {
174
+ name: options.eventName || null,
175
+ id: options.eventId || null,
176
+ },
177
+ date: {
178
+ timestamp: now,
179
+ timestampUNIX: nowUNIX,
180
+ },
158
181
  },
159
182
  },
160
183
  };
@@ -194,9 +217,6 @@ function resolveStatus(raw) {
194
217
  * Handles cancel_at_period_end for pending cancellations
195
218
  */
196
219
  function resolveCancellation(raw) {
197
- const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
198
- const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
199
-
200
220
  // Pending cancellation: active but set to cancel at period end
201
221
  if (raw.cancel_at_period_end) {
202
222
  const periodEnd = raw.current_period_end || raw.items?.data?.[0]?.current_period_end || 0;
@@ -230,8 +250,8 @@ function resolveCancellation(raw) {
230
250
  return {
231
251
  pending: false,
232
252
  date: {
233
- timestamp: oldDate,
234
- timestampUNIX: oldDateUNIX,
253
+ timestamp: EPOCH_ZERO,
254
+ timestampUNIX: EPOCH_ZERO_UNIX,
235
255
  },
236
256
  };
237
257
  }
@@ -240,15 +260,12 @@ function resolveCancellation(raw) {
240
260
  * Resolve trial state from Stripe subscription
241
261
  */
242
262
  function resolveTrial(raw) {
243
- const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
244
- const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
245
-
246
263
  const trialStart = raw.trial_start ? raw.trial_start * 1000 : 0;
247
264
  const trialEnd = raw.trial_end ? raw.trial_end * 1000 : 0;
248
265
  const activated = !!(trialStart && trialEnd);
249
266
 
250
267
  // Build trial expiration
251
- let trialExpires = { timestamp: oldDate, timestampUNIX: oldDateUNIX };
268
+ let trialExpires = { timestamp: EPOCH_ZERO, timestampUNIX: EPOCH_ZERO_UNIX };
252
269
  if (trialEnd) {
253
270
  const trialEndDate = powertools.timestamp(new Date(trialEnd), { output: 'string' });
254
271
  trialExpires = {
@@ -272,23 +289,7 @@ function resolveFrequency(raw) {
272
289
  || raw.items?.data?.[0]?.price?.recurring?.interval
273
290
  || null;
274
291
 
275
- if (interval === 'year') {
276
- return 'annually';
277
- }
278
-
279
- if (interval === 'month') {
280
- return 'monthly';
281
- }
282
-
283
- if (interval === 'week') {
284
- return 'weekly';
285
- }
286
-
287
- if (interval === 'day') {
288
- return 'daily';
289
- }
290
-
291
- return null;
292
+ return INTERVAL_TO_FREQUENCY[interval] || null;
292
293
  }
293
294
 
294
295
  /**
@@ -322,10 +323,28 @@ function resolveProduct(raw, config) {
322
323
  return { id: 'basic', name: 'Basic' };
323
324
  }
324
325
 
326
+ /**
327
+ * Resolve product for one-time payments by matching productId from metadata
328
+ * Returns { id, name } — falls back to 'unknown' if no match is found
329
+ */
330
+ function resolveProductOneTime(productId, config) {
331
+ if (!productId || !config.payment?.products) {
332
+ return { id: productId || 'unknown', name: 'Unknown' };
333
+ }
334
+
335
+ const product = config.payment.products.find(p => p.id === productId);
336
+
337
+ if (!product) {
338
+ return { id: productId, name: productId };
339
+ }
340
+
341
+ return { id: product.id, name: product.name || product.id };
342
+ }
343
+
325
344
  /**
326
345
  * Resolve subscription expiration from Stripe data
327
346
  */
328
- function resolveExpires(raw, oldDate, oldDateUNIX) {
347
+ function resolveExpires(raw) {
329
348
  // Stripe API 2025+ moves period dates to items.data[0]
330
349
  const periodEndRaw = raw.current_period_end
331
350
  || raw.items?.data?.[0]?.current_period_end
@@ -333,30 +352,52 @@ function resolveExpires(raw, oldDate, oldDateUNIX) {
333
352
 
334
353
  const periodEnd = periodEndRaw
335
354
  ? powertools.timestamp(new Date(periodEndRaw * 1000), { output: 'string' })
336
- : oldDate;
355
+ : EPOCH_ZERO;
337
356
 
338
357
  return {
339
358
  timestamp: periodEnd,
340
- timestampUNIX: periodEnd !== oldDate
359
+ timestampUNIX: periodEnd !== EPOCH_ZERO
341
360
  ? powertools.timestamp(periodEnd, { output: 'unix' })
342
- : oldDateUNIX,
361
+ : EPOCH_ZERO_UNIX,
343
362
  };
344
363
  }
345
364
 
346
365
  /**
347
366
  * Resolve subscription start date from Stripe data
348
367
  */
349
- function resolveStartDate(raw, oldDate, oldDateUNIX) {
368
+ function resolveStartDate(raw) {
350
369
  const startDate = raw.start_date
351
370
  ? powertools.timestamp(new Date(raw.start_date * 1000), { output: 'string' })
352
- : oldDate;
371
+ : EPOCH_ZERO;
353
372
 
354
373
  return {
355
374
  timestamp: startDate,
356
- timestampUNIX: startDate !== oldDate
375
+ timestampUNIX: startDate !== EPOCH_ZERO
357
376
  ? powertools.timestamp(startDate, { output: 'unix' })
358
- : oldDateUNIX,
377
+ : EPOCH_ZERO_UNIX,
359
378
  };
360
379
  }
361
380
 
381
+ /**
382
+ * Resolve the display price for a product/frequency from config
383
+ *
384
+ * @param {string} productId - Product ID (e.g., 'premium')
385
+ * @param {string} frequency - 'monthly', 'annually', or 'once'
386
+ * @param {object} config - App config
387
+ * @returns {number} Price amount (e.g., 4.99) or 0
388
+ */
389
+ function resolvePrice(productId, frequency, config) {
390
+ const product = config.payment?.products?.find(p => p.id === productId);
391
+
392
+ if (!product) {
393
+ return 0;
394
+ }
395
+
396
+ if (frequency === 'once') {
397
+ return product.prices?.once?.amount || 0;
398
+ }
399
+
400
+ return product.prices?.[frequency]?.amount || 0;
401
+ }
402
+
362
403
  module.exports = Stripe;
@@ -26,18 +26,21 @@ 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 .unified
38
41
  // Reconstruct a Stripe-shaped object from the unified data for toUnifiedSubscription()
39
- if (resourceType === 'subscription' && data.subscription) {
40
- return buildStripeSubscriptionFromUnified(data.subscription, resourceId, context?.eventType, context?.config);
42
+ if (resourceType === 'subscription' && data.unified) {
43
+ return buildStripeSubscriptionFromUnified(data.unified, resourceId, context?.eventType, context?.config);
41
44
  }
42
45
  }
43
46
  }
@@ -62,7 +65,7 @@ const Test = {
62
65
  */
63
66
  toUnifiedOneTime(rawResource, options) {
64
67
  const unified = Stripe.toUnifiedOneTime(rawResource, options);
65
- unified.processor = 'test';
68
+ unified.payment.processor = 'test';
66
69
  return unified;
67
70
  },
68
71
  };
@@ -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,15 @@ 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
+
74
+ // Build redirect URLs
75
+ const confirmationUrl = buildConfirmationUrl(Manager.project.websiteUrl, { product, productId, productType, frequency, processor, trial, orderId });
76
+ const cancelUrl = buildCancelUrl(Manager.project.websiteUrl, { productId, frequency });
77
+
67
78
  // Load the processor module
68
79
  let processorModule;
69
80
  try {
@@ -77,10 +88,13 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
77
88
  try {
78
89
  result = await processorModule.createIntent({
79
90
  uid,
91
+ orderId,
80
92
  product,
81
93
  productId,
82
94
  frequency,
83
95
  trial,
96
+ confirmationUrl,
97
+ cancelUrl,
84
98
  config: Manager.config,
85
99
  Manager,
86
100
  assistant,
@@ -96,11 +110,12 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
96
110
  const now = powertools.timestamp(new Date(), { output: 'string' });
97
111
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
98
112
 
99
- // Save to payments-intents collection
100
- await admin.firestore().doc(`payments-intents/${result.id}`).set({
101
- id: result.id,
113
+ // Save to payments-intents collection (keyed by orderId for consistent lookup with payments-orders)
114
+ await admin.firestore().doc(`payments-intents/${orderId}`).set({
115
+ id: orderId,
116
+ intentId: result.id,
102
117
  processor: processor,
103
- uid: uid,
118
+ owner: uid,
104
119
  status: 'pending',
105
120
  productId: productId,
106
121
  productType: productType,
@@ -115,10 +130,49 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
115
130
  },
116
131
  });
117
132
 
118
- assistant.log(`Saved payments-intents/${result.id}: uid=${uid}, product=${productId}, type=${productType}, frequency=${frequency}, trial=${trial}`);
133
+ assistant.log(`Saved payments-intents/${orderId}: uid=${uid}, product=${productId}, type=${productType}, frequency=${frequency}, trial=${trial}`);
119
134
 
120
135
  return assistant.respond({
121
136
  id: result.id,
137
+ orderId: orderId,
122
138
  url: result.url,
123
139
  });
124
140
  };
141
+
142
+ /**
143
+ * Build the confirmation/success redirect URL
144
+ */
145
+ function buildConfirmationUrl(baseUrl, { product, productId, productType, frequency, processor, trial, orderId }) {
146
+ const amount = productType === 'subscription'
147
+ ? (product.prices?.[frequency]?.amount || 0)
148
+ : (product.prices?.once?.amount || 0);
149
+
150
+ const url = new URL('/payment/confirmation', baseUrl);
151
+ url.searchParams.set('productId', productId);
152
+ url.searchParams.set('productName', product.name || productId);
153
+ url.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
154
+ url.searchParams.set('currency', 'USD');
155
+ url.searchParams.set('frequency', frequency || 'once');
156
+ url.searchParams.set('paymentMethod', processor);
157
+ url.searchParams.set('trial', String(!!trial && !!product.trial?.days));
158
+ url.searchParams.set('orderId', orderId);
159
+ url.searchParams.set('track', 'true');
160
+
161
+ return url.toString();
162
+ }
163
+
164
+ /**
165
+ * Build the cancel/back redirect URL
166
+ */
167
+ function buildCancelUrl(baseUrl, { productId, frequency }) {
168
+ const url = new URL('/payment/checkout', baseUrl);
169
+ url.searchParams.set('product', productId);
170
+
171
+ if (frequency) {
172
+ url.searchParams.set('frequency', frequency);
173
+ }
174
+
175
+ url.searchParams.set('payment', 'cancelled');
176
+
177
+ return url.toString();
178
+ }