backend-manager 5.0.103 → 5.0.105

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 (60) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +113 -24
  3. package/README.md +8 -0
  4. package/TODO-PAYMENT-v2.md +5 -2
  5. package/package.json +1 -1
  6. package/src/cli/commands/deploy.js +2 -4
  7. package/src/cli/commands/emulator.js +30 -1
  8. package/src/cli/commands/test.js +33 -2
  9. package/src/manager/events/firestore/payments-webhooks/on-write.js +17 -3
  10. package/src/manager/events/firestore/payments-webhooks/transitions/index.js +6 -0
  11. package/src/manager/libraries/payment/processors/paypal.js +587 -0
  12. package/src/manager/libraries/{payment-processors → payment/processors}/stripe.js +86 -18
  13. package/src/manager/libraries/{payment-processors → payment/processors}/test.js +15 -8
  14. package/src/manager/routes/payments/cancel/processors/paypal.js +30 -0
  15. package/src/manager/routes/payments/cancel/processors/stripe.js +1 -1
  16. package/src/manager/routes/payments/cancel/processors/test.js +4 -6
  17. package/src/manager/routes/payments/intent/post.js +3 -3
  18. package/src/manager/routes/payments/intent/processors/paypal.js +150 -0
  19. package/src/manager/routes/payments/intent/processors/stripe.js +3 -5
  20. package/src/manager/routes/payments/intent/processors/test.js +7 -8
  21. package/src/manager/routes/payments/portal/processors/paypal.js +24 -0
  22. package/src/manager/routes/payments/portal/processors/stripe.js +1 -1
  23. package/src/manager/routes/payments/refund/post.js +85 -0
  24. package/src/manager/routes/payments/refund/processors/paypal.js +117 -0
  25. package/src/manager/routes/payments/refund/processors/stripe.js +103 -0
  26. package/src/manager/routes/payments/refund/processors/test.js +98 -0
  27. package/src/manager/routes/payments/webhook/processors/paypal.js +137 -0
  28. package/src/manager/schemas/payments/refund/post.js +18 -0
  29. package/src/test/test-accounts.js +46 -0
  30. package/templates/backend-manager-config.json +20 -24
  31. package/test/events/payments/journey-payments-cancel.js +3 -3
  32. package/test/events/payments/journey-payments-failure.js +1 -1
  33. package/test/events/payments/journey-payments-one-time.js +1 -1
  34. package/test/events/payments/journey-payments-plan-change.js +4 -4
  35. package/test/events/payments/journey-payments-suspend.js +3 -3
  36. package/test/events/payments/journey-payments-trial.js +2 -2
  37. package/test/fixtures/paypal/order-approved.json +62 -0
  38. package/test/fixtures/paypal/order-completed.json +110 -0
  39. package/test/fixtures/paypal/subscription-active.json +76 -0
  40. package/test/fixtures/paypal/subscription-cancelled.json +50 -0
  41. package/test/fixtures/paypal/subscription-suspended.json +65 -0
  42. package/test/helpers/payment/paypal/parse-webhook.js +539 -0
  43. package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
  44. package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
  45. package/test/helpers/{stripe-parse-webhook.js → payment/stripe/parse-webhook.js} +4 -4
  46. package/test/helpers/{stripe-to-unified-one-time.js → payment/stripe/to-unified-one-time.js} +8 -6
  47. package/test/helpers/{stripe-to-unified.js → payment/stripe/to-unified-subscription.js} +40 -33
  48. package/test/routes/payments/refund.js +174 -0
  49. package/src/manager/libraries/payment-processors/resolve-price-id.js +0 -19
  50. package/src/manager/routes/forms/delete.js +0 -37
  51. package/src/manager/routes/forms/get.js +0 -46
  52. package/src/manager/routes/forms/post.js +0 -45
  53. package/src/manager/routes/forms/public/get.js +0 -37
  54. package/src/manager/routes/forms/put.js +0 -52
  55. package/src/manager/schemas/forms/delete.js +0 -6
  56. package/src/manager/schemas/forms/get.js +0 -6
  57. package/src/manager/schemas/forms/post.js +0 -9
  58. package/src/manager/schemas/forms/public/get.js +0 -6
  59. package/src/manager/schemas/forms/put.js +0 -10
  60. /package/src/manager/libraries/{payment-processors → payment}/order-id.js +0 -0
@@ -71,6 +71,16 @@ const Stripe = {
71
71
  }
72
72
  },
73
73
 
74
+ /**
75
+ * Extract the internal orderId from a Stripe resource
76
+ *
77
+ * @param {object} resource - Raw Stripe resource (subscription, session, invoice)
78
+ * @returns {string|null}
79
+ */
80
+ getOrderId(resource) {
81
+ return resource.metadata?.orderId || null;
82
+ },
83
+
74
84
  /**
75
85
  * Transform a raw Stripe subscription object into the unified subscription shape
76
86
  * This produces the exact same object stored in users/{uid}.subscription
@@ -178,6 +188,69 @@ const Stripe = {
178
188
  return customer;
179
189
  },
180
190
 
191
+ /**
192
+ * Resolve the Stripe price ID by fetching active prices from the Stripe product
193
+ * and matching by interval + amount.
194
+ *
195
+ * @param {object} product - Product object from config (must have .prices and .stripe.productId)
196
+ * @param {string} productType - 'subscription' or 'one-time'
197
+ * @param {string} frequency - 'monthly', 'annually', etc. (subscriptions) — ignored for one-time
198
+ * @returns {Promise<string>} Stripe price ID
199
+ * @throws {Error} If product is archived, missing Stripe product ID, or no matching price found
200
+ */
201
+ async resolvePriceId(product, productType, frequency) {
202
+ if (product.archived) {
203
+ throw new Error(`Product ${product.id} is archived`);
204
+ }
205
+
206
+ const stripeProductId = product.stripe?.productId;
207
+
208
+ if (!stripeProductId) {
209
+ throw new Error(`No Stripe product ID for ${product.id}`);
210
+ }
211
+
212
+ const key = productType === 'subscription' ? frequency : 'once';
213
+ const expectedAmount = product.prices?.[key];
214
+
215
+ if (!expectedAmount) {
216
+ throw new Error(`No price configured for ${product.id}/${key}`);
217
+ }
218
+
219
+ const amountCents = Math.round(expectedAmount * 100);
220
+
221
+ // Fetch active prices from Stripe for this product
222
+ const stripe = this.init();
223
+
224
+ const prices = [];
225
+ for await (const price of stripe.prices.list({ product: stripeProductId, active: true, limit: 100 })) {
226
+ prices.push(price);
227
+ }
228
+
229
+ // Match by interval + amount
230
+ if (productType === 'subscription') {
231
+ const interval = frequency === 'annually' ? 'year' : 'month';
232
+ const match = prices.find(p =>
233
+ p.recurring?.interval === interval
234
+ && p.unit_amount === amountCents
235
+ );
236
+
237
+ if (!match) {
238
+ throw new Error(`No active Stripe price for ${product.id}/${frequency} at $${expectedAmount} (product: ${stripeProductId})`);
239
+ }
240
+
241
+ return match.id;
242
+ }
243
+
244
+ // One-time: match by amount, no recurring
245
+ const match = prices.find(p => !p.recurring && p.unit_amount === amountCents);
246
+
247
+ if (!match) {
248
+ throw new Error(`No active Stripe price for ${product.id}/once at $${expectedAmount} (product: ${stripeProductId})`);
249
+ }
250
+
251
+ return match.id;
252
+ },
253
+
181
254
  /**
182
255
  * Transform a raw Stripe one-time payment resource into a unified shape
183
256
  * Mirrors subscription structure: { product, status, payment: { ... } }
@@ -330,29 +403,28 @@ function resolveFrequency(raw) {
330
403
  }
331
404
 
332
405
  /**
333
- * Resolve product by matching the Stripe price ID against config products
406
+ * Resolve product by matching the Stripe product ID against config products
334
407
  * Returns { id, name } — falls back to basic if no match is found
335
408
  */
336
409
  function resolveProduct(raw, config) {
337
- // Get the price ID from the subscription
338
- const priceId = raw.plan?.id
339
- || raw.items?.data?.[0]?.price?.id
410
+ // Get the Stripe product ID from the subscription
411
+ const stripeProductId = raw.items?.data?.[0]?.price?.product
412
+ || raw.plan?.product
340
413
  || null;
341
414
 
342
- if (!priceId || !config.payment?.products) {
415
+ if (!stripeProductId || !config.payment?.products) {
343
416
  return { id: 'basic', name: 'Basic' };
344
417
  }
345
418
 
346
- // Search through products for a matching price ID
347
419
  for (const product of config.payment.products) {
348
- if (!product.prices) {
349
- continue;
420
+ // Match current product ID
421
+ if (product.stripe?.productId === stripeProductId) {
422
+ return { id: product.id, name: product.name || product.id };
350
423
  }
351
424
 
352
- for (const frequency of Object.keys(product.prices)) {
353
- if (product.prices[frequency]?.stripe === priceId) {
354
- return { id: product.id, name: product.name || product.id };
355
- }
425
+ // Match legacy product IDs (pre-migration Stripe products)
426
+ if (product.stripe?.legacyProductIds?.includes(stripeProductId)) {
427
+ return { id: product.id, name: product.name || product.id };
356
428
  }
357
429
  }
358
430
 
@@ -426,15 +498,11 @@ function resolveStartDate(raw) {
426
498
  function resolvePrice(productId, frequency, config) {
427
499
  const product = config.payment?.products?.find(p => p.id === productId);
428
500
 
429
- if (!product) {
501
+ if (!product || !product.prices) {
430
502
  return 0;
431
503
  }
432
504
 
433
- if (frequency === 'once') {
434
- return product.prices?.once?.amount || 0;
435
- }
436
-
437
- return product.prices?.[frequency]?.amount || 0;
505
+ return product.prices[frequency] || 0;
438
506
  }
439
507
 
440
508
  module.exports = Stripe;
@@ -49,6 +49,13 @@ const Test = {
49
49
  return rawFallback;
50
50
  },
51
51
 
52
+ /**
53
+ * Extract orderId — delegates to Stripe (test processor uses Stripe-shaped data)
54
+ */
55
+ getOrderId(resource) {
56
+ return Stripe.getOrderId(resource);
57
+ },
58
+
52
59
  /**
53
60
  * Transform raw subscription into unified shape
54
61
  * Delegates to Stripe's toUnifiedSubscription (same data shape), stamps processor as 'test'
@@ -103,11 +110,11 @@ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, conf
103
110
  status = 'past_due';
104
111
  }
105
112
 
106
- // Resolve the Stripe price ID from product + frequency via config
113
+ // Resolve the Stripe product ID from config
107
114
  // This is needed for resolveProduct() in toUnifiedSubscription() to match the correct product
108
115
  const frequency = unified.payment?.frequency;
109
116
  const productId = unified.product?.id;
110
- const priceId = resolvePriceId(productId, frequency, config);
117
+ const stripeProductId = resolveStripeProductId(productId, config);
111
118
 
112
119
  return {
113
120
  id: resourceId,
@@ -115,7 +122,7 @@ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, conf
115
122
  status: status,
116
123
  metadata: { orderId: unified.payment?.orderId || null },
117
124
  plan: {
118
- id: priceId,
125
+ product: stripeProductId,
119
126
  interval: INTERVAL_MAP[frequency] || 'month',
120
127
  },
121
128
  current_period_end: unified.expires?.timestampUNIX || 0,
@@ -130,15 +137,15 @@ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, conf
130
137
  }
131
138
 
132
139
  /**
133
- * Look up the Stripe price ID from config given a product ID and frequency
134
- * e.g., ('plus', 'monthly') → 'price_plus_monthly'
140
+ * Look up the Stripe product ID from config given a product ID
141
+ * e.g., ('plus') → 'prod_plus'
135
142
  */
136
- function resolvePriceId(productId, frequency, config) {
137
- if (!productId || !frequency || !config?.payment?.products) {
143
+ function resolveStripeProductId(productId, config) {
144
+ if (!productId || !config?.payment?.products) {
138
145
  return null;
139
146
  }
140
147
 
141
148
  const product = config.payment.products.find(p => p.id === productId);
142
149
 
143
- return product?.prices?.[frequency]?.stripe || null;
150
+ return product?.stripe?.productId || null;
144
151
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * PayPal cancel processor
3
+ * Cancels a PayPal subscription
4
+ *
5
+ * Note: PayPal does not have "cancel at period end" like Stripe.
6
+ * Cancellation takes effect immediately, but the subscriber retains access
7
+ * until the end of the current billing period (PayPal's default behavior).
8
+ */
9
+ module.exports = {
10
+ /**
11
+ * Cancel a PayPal subscription
12
+ *
13
+ * @param {object} options
14
+ * @param {string} options.resourceId - PayPal subscription ID (e.g., 'I-xxx')
15
+ * @param {string} options.uid - User's UID (for logging)
16
+ * @param {object} options.assistant - Assistant instance for logging
17
+ */
18
+ async cancelAtPeriodEnd({ resourceId, uid, assistant }) {
19
+ const PayPalLib = require('../../../../libraries/payment/processors/paypal.js');
20
+
21
+ await PayPalLib.request(`/v1/billing/subscriptions/${resourceId}/cancel`, {
22
+ method: 'POST',
23
+ body: JSON.stringify({
24
+ reason: 'Customer requested cancellation',
25
+ }),
26
+ });
27
+
28
+ assistant.log(`PayPal subscription cancelled: sub=${resourceId}, uid=${uid}`);
29
+ },
30
+ };
@@ -12,7 +12,7 @@ module.exports = {
12
12
  * @param {object} options.assistant - Assistant instance for logging
13
13
  */
14
14
  async cancelAtPeriodEnd({ resourceId, uid, assistant }) {
15
- const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
15
+ const StripeLib = require('../../../../libraries/payment/processors/stripe.js');
16
16
  const stripe = StripeLib.init();
17
17
 
18
18
  await stripe.subscriptions.update(resourceId, { cancel_at_period_end: true });
@@ -20,20 +20,18 @@ module.exports = {
20
20
  const now = Math.floor(timestamp / 1000);
21
21
  const periodEnd = now + (30 * 86400);
22
22
 
23
- // Look up the price ID from the existing order so toUnifiedSubscription can resolve the product
23
+ // Look up the Stripe product ID from the existing order so resolveProduct() can match
24
24
  const orderId = subscription?.payment?.orderId;
25
- let priceId = null;
25
+ let stripeProductId = null;
26
26
 
27
27
  if (orderId) {
28
28
  const orderDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
29
29
  if (orderDoc.exists) {
30
30
  const orderData = orderDoc.data();
31
- // Find the matching price from config using frequency
32
- const frequency = orderData.unified?.payment?.frequency;
33
31
  const productId = orderData.unified?.product?.id;
34
32
  const products = assistant.Manager.config.payment?.products || [];
35
33
  const product = products.find(p => p.id === productId);
36
- priceId = product?.prices?.[frequency]?.stripe || null;
34
+ stripeProductId = product?.stripe?.productId || null;
37
35
  }
38
36
  }
39
37
 
@@ -52,7 +50,7 @@ module.exports = {
52
50
  start_date: now - (30 * 86400),
53
51
  trial_start: null,
54
52
  trial_end: null,
55
- plan: { id: priceId, interval: 'month' },
53
+ plan: { product: stripeProductId, interval: 'month' },
56
54
  };
57
55
 
58
56
  const nowTs = powertools.timestamp(new Date(), { output: 'string' });
@@ -1,6 +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
+ const OrderId = require('../../../libraries/payment/order-id.js');
4
4
 
5
5
  /**
6
6
  * POST /payments/intent
@@ -142,8 +142,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
142
142
  */
143
143
  function buildConfirmationUrl(baseUrl, { product, productId, productType, frequency, processor, trial, orderId }) {
144
144
  const amount = productType === 'subscription'
145
- ? (product.prices?.[frequency]?.amount || 0)
146
- : (product.prices?.once?.amount || 0);
145
+ ? (product.prices?.[frequency] || 0)
146
+ : (product.prices?.once || 0);
147
147
 
148
148
  const url = new URL('/payment/confirmation', baseUrl);
149
149
  url.searchParams.set('productId', productId);
@@ -0,0 +1,150 @@
1
+ /**
2
+ * PayPal intent processor
3
+ * Creates PayPal subscriptions (Billing API) and one-time orders (Orders API v2)
4
+ */
5
+ module.exports = {
6
+ /**
7
+ * Create a PayPal payment intent (subscription or one-time order)
8
+ *
9
+ * @param {object} options
10
+ * @param {string} options.uid - User's UID
11
+ * @param {string} options.orderId - Internal order ID
12
+ * @param {object} options.product - Full product object from config
13
+ * @param {string} options.productId - Product ID from config (e.g., 'premium')
14
+ * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
15
+ * @param {boolean} options.trial - Whether to include a trial period
16
+ * @param {string} options.confirmationUrl - Success redirect URL
17
+ * @param {string} options.cancelUrl - Cancel redirect URL
18
+ * @param {object} options.assistant - Assistant instance for logging
19
+ * @returns {object} { id, url, raw }
20
+ */
21
+ async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant }) {
22
+ const PayPalLib = require('../../../../libraries/payment/processors/paypal.js');
23
+
24
+ const productType = product.type || 'subscription';
25
+
26
+ if (productType === 'subscription') {
27
+ return createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant, PayPalLib });
28
+ }
29
+
30
+ return createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, cancelUrl, assistant, PayPalLib });
31
+ },
32
+ };
33
+
34
+ /**
35
+ * Create a PayPal subscription via the Billing Subscriptions API
36
+ */
37
+ async function createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant, PayPalLib }) {
38
+ // Resolve the PayPal plan ID at runtime (fetches plans from product, matches by interval + amount)
39
+ const planId = await PayPalLib.resolvePlanId(product, frequency);
40
+
41
+ assistant.log(`PayPal subscription: planId=${planId}, uid=${uid}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
42
+
43
+ // Build subscription request
44
+ const subscriptionParams = {
45
+ plan_id: planId,
46
+ custom_id: PayPalLib.buildCustomId(uid, orderId),
47
+ application_context: {
48
+ brand_name: product.name || productId,
49
+ return_url: confirmationUrl,
50
+ cancel_url: cancelUrl,
51
+ user_action: 'SUBSCRIBE_NOW',
52
+ shipping_preference: 'NO_SHIPPING',
53
+ },
54
+ };
55
+
56
+ // Add trial override if needed
57
+ // If trial is requested and product has trial days, override the plan's setup
58
+ // If trial is NOT requested but plan has trial, skip it by setting start_time to now
59
+ if (trial && product.trial?.days) {
60
+ // Let the plan's trial cycle handle it (if configured)
61
+ // PayPal trials are configured on the plan, not at subscription creation
62
+ assistant.log('PayPal trial: using plan trial cycle');
63
+ } else if (!trial) {
64
+ // Skip trial by starting billing immediately
65
+ const now = new Date();
66
+ now.setMinutes(now.getMinutes() + 5); // PayPal requires start_time in future
67
+ subscriptionParams.start_time = now.toISOString();
68
+ }
69
+
70
+ // Create the subscription
71
+ const subscription = await PayPalLib.request('/v1/billing/subscriptions', {
72
+ method: 'POST',
73
+ body: JSON.stringify(subscriptionParams),
74
+ });
75
+
76
+ // Extract approval URL
77
+ const approvalLink = subscription.links?.find(l => l.rel === 'approve');
78
+
79
+ if (!approvalLink) {
80
+ throw new Error('PayPal subscription created but no approval URL returned');
81
+ }
82
+
83
+ assistant.log(`PayPal subscription created: id=${subscription.id}, url=${approvalLink.href}`);
84
+
85
+ return {
86
+ id: subscription.id,
87
+ url: approvalLink.href,
88
+ raw: subscription,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Create a PayPal one-time order via the Orders API v2
94
+ */
95
+ async function createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, cancelUrl, assistant, PayPalLib }) {
96
+ if (product.archived) {
97
+ throw new Error(`Product ${product.id} is archived`);
98
+ }
99
+
100
+ const amount = product.prices?.once;
101
+
102
+ if (!amount) {
103
+ throw new Error(`No one-time price configured for ${product.id}`);
104
+ }
105
+
106
+ const brandName = assistant.Manager?.config?.brand?.name || product.name || productId;
107
+
108
+ const orderParams = {
109
+ intent: 'CAPTURE',
110
+ purchase_units: [{
111
+ amount: {
112
+ currency_code: 'USD',
113
+ value: amount.toFixed(2),
114
+ },
115
+ description: product.name || productId,
116
+ custom_id: PayPalLib.buildCustomId(uid, orderId, productId),
117
+ }],
118
+ payment_source: {
119
+ paypal: {
120
+ experience_context: {
121
+ brand_name: brandName,
122
+ return_url: confirmationUrl,
123
+ cancel_url: cancelUrl,
124
+ user_action: 'PAY_NOW',
125
+ shipping_preference: 'NO_SHIPPING',
126
+ },
127
+ },
128
+ },
129
+ };
130
+
131
+ const order = await PayPalLib.request('/v2/checkout/orders', {
132
+ method: 'POST',
133
+ body: JSON.stringify(orderParams),
134
+ });
135
+
136
+ // Extract approval URL
137
+ const approvalLink = order.links?.find(l => l.rel === 'payer-action' || l.rel === 'approve');
138
+
139
+ if (!approvalLink) {
140
+ throw new Error('PayPal order created but no approval URL returned');
141
+ }
142
+
143
+ assistant.log(`PayPal order created: id=${order.id}, url=${approvalLink.href}`);
144
+
145
+ return {
146
+ id: order.id,
147
+ url: approvalLink.href,
148
+ raw: order,
149
+ };
150
+ }
@@ -1,5 +1,3 @@
1
- const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
2
-
3
1
  /**
4
2
  * Stripe intent processor
5
3
  * Creates Stripe Checkout Sessions for subscription and one-time purchases
@@ -20,13 +18,13 @@ module.exports = {
20
18
  */
21
19
  async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant }) {
22
20
  // Initialize Stripe SDK
23
- const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
21
+ const StripeLib = require('../../../../libraries/payment/processors/stripe.js');
24
22
  const stripe = StripeLib.init();
25
23
 
26
24
  const productType = product.type || 'subscription';
27
25
 
28
- // Resolve the Stripe price ID based on product type
29
- const priceId = resolvePriceId(product, productType, frequency);
26
+ // Resolve the Stripe price ID at runtime (fetches active prices from Stripe product)
27
+ const priceId = await StripeLib.resolvePriceId(product, productType, frequency);
30
28
 
31
29
  // Resolve or create Stripe customer (keyed by uid in metadata)
32
30
  const email = assistant?.getUser()?.auth?.email || null;
@@ -1,5 +1,4 @@
1
1
  const fetch = require('wonderful-fetch');
2
- const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
3
2
 
4
3
  /**
5
4
  * Test intent processor
@@ -43,9 +42,6 @@ module.exports = {
43
42
  * Generates Stripe-shaped subscription + customer.subscription.created event
44
43
  */
45
44
  async function createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, assistant }) {
46
- // Get the price ID for the requested frequency
47
- const priceId = resolvePriceId(product, 'subscription', frequency);
48
-
49
45
  // Generate IDs
50
46
  const timestamp = Date.now();
51
47
  const sessionId = `_test-cs-${timestamp}`;
@@ -62,12 +58,13 @@ async function createSubscriptionIntent({ uid, orderId, product, frequency, tria
62
58
  : now + (30 * 86400);
63
59
 
64
60
  // Build Stripe-shaped subscription object
61
+ // Uses product's Stripe product ID so resolveProduct() can match it
65
62
  const subscription = {
66
63
  id: subscriptionId,
67
64
  object: 'subscription',
68
65
  status: trial && product.trial?.days ? 'trialing' : 'active',
69
66
  metadata: { uid, orderId },
70
- plan: { id: priceId, interval },
67
+ plan: { product: product.stripe?.productId || null, interval },
71
68
  current_period_end: periodEnd,
72
69
  current_period_start: now,
73
70
  start_date: now,
@@ -109,8 +106,10 @@ async function createSubscriptionIntent({ uid, orderId, product, frequency, tria
109
106
  * Generates Stripe-shaped checkout session + checkout.session.completed event
110
107
  */
111
108
  async function createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, assistant }) {
112
- // Validate that a price exists (resolvePriceId throws if not found)
113
- resolvePriceId(product, 'one-time', null);
109
+ // Validate that a price exists
110
+ if (!product.prices?.once) {
111
+ throw new Error(`No one-time price configured for ${product.id}`);
112
+ }
114
113
 
115
114
  // Generate IDs
116
115
  const timestamp = Date.now();
@@ -125,7 +124,7 @@ async function createOneTimeIntent({ uid, orderId, product, productId, confirmat
125
124
  status: 'complete',
126
125
  payment_status: 'paid',
127
126
  metadata: { uid, orderId, productId },
128
- amount_total: Math.round((product.prices?.once?.amount || 0) * 100),
127
+ amount_total: Math.round((product.prices.once || 0) * 100),
129
128
  currency: 'usd',
130
129
  };
131
130
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * PayPal portal processor
3
+ * PayPal does not have a hosted billing portal like Stripe.
4
+ * Returns a link to PayPal's subscription management page.
5
+ */
6
+ module.exports = {
7
+ /**
8
+ * Get the PayPal subscription management URL
9
+ *
10
+ * @param {object} options
11
+ * @param {string} options.uid - User's UID
12
+ * @param {string|null} options.returnUrl - Not used for PayPal
13
+ * @param {object} options.assistant - Assistant instance for logging
14
+ * @returns {object} { url }
15
+ */
16
+ async createPortalSession({ uid, returnUrl, assistant }) {
17
+ // PayPal subscribers manage their subscription directly at PayPal
18
+ const url = 'https://www.paypal.com/myaccount/autopay/';
19
+
20
+ assistant.log(`PayPal portal redirect: uid=${uid}, url=${url}`);
21
+
22
+ return { url };
23
+ },
24
+ };
@@ -19,7 +19,7 @@ module.exports = {
19
19
  * @returns {object} { url }
20
20
  */
21
21
  async createPortalSession({ uid, email, returnUrl, assistant }) {
22
- const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
22
+ const StripeLib = require('../../../../libraries/payment/processors/stripe.js');
23
23
  const stripe = StripeLib.init();
24
24
 
25
25
  // Resolve the Stripe customer for this user
@@ -0,0 +1,85 @@
1
+ const path = require('path');
2
+
3
+ /**
4
+ * POST /payments/refund
5
+ * Refunds the authenticated user's subscription and cancels it immediately.
6
+ * Requires the subscription to be cancelled or pending cancellation first.
7
+ *
8
+ * Delegates to the processor (e.g., Stripe) to issue the refund and cancel.
9
+ * The resulting webhook triggers the Firestore pipeline which updates subscription state
10
+ * and fires the subscription-cancelled transition handler.
11
+ * Requires authentication.
12
+ */
13
+ module.exports = async ({ assistant, user, settings }) => {
14
+ // Require authentication
15
+ if (!user.authenticated) {
16
+ return assistant.respond('Authentication required', { code: 401 });
17
+ }
18
+
19
+ const uid = user.auth.uid;
20
+ const confirmed = settings.confirmed;
21
+
22
+ // Require explicit confirmation
23
+ if (!confirmed) {
24
+ return assistant.respond('Refund must be confirmed', { code: 400 });
25
+ }
26
+
27
+ const subscription = user.subscription;
28
+
29
+ // Require a paid subscription
30
+ if (!subscription || subscription.product?.id === 'basic') {
31
+ assistant.log(`Refund rejected: uid=${uid}, no paid subscription`);
32
+ return assistant.respond('No paid subscription found', { code: 400 });
33
+ }
34
+
35
+ // Require cancelled or pending cancellation — cannot refund an active subscription
36
+ const isCancelled = subscription.status === 'cancelled';
37
+ const isPendingCancel = subscription.cancellation?.pending === true;
38
+
39
+ if (!isCancelled && !isPendingCancel) {
40
+ assistant.log(`Refund rejected: uid=${uid}, status=${subscription.status}, pending=${subscription.cancellation?.pending}`);
41
+ return assistant.respond('Subscription must be cancelled or pending cancellation before requesting a refund', { code: 400 });
42
+ }
43
+
44
+ // Reject if the most recent payment is older than 6 months
45
+ const startDateUNIX = subscription.payment?.startDate?.timestampUNIX
46
+ || subscription.payment?.updatedBy?.date?.timestampUNIX;
47
+
48
+ if (startDateUNIX) {
49
+ const sixMonthsAgoUNIX = Math.floor(Date.now() / 1000) - (6 * 30 * 24 * 60 * 60);
50
+
51
+ if (startDateUNIX < sixMonthsAgoUNIX) {
52
+ assistant.log(`Refund rejected: uid=${uid}, payment too old (startDate=${new Date(startDateUNIX * 1000).toISOString()})`);
53
+ return assistant.respond('Payments older than 6 months are not eligible for refunds', { code: 400 });
54
+ }
55
+ }
56
+
57
+ const processor = subscription.payment?.processor;
58
+ const resourceId = subscription.payment?.resourceId;
59
+
60
+ if (!processor || !resourceId) {
61
+ assistant.log(`Refund rejected: uid=${uid}, missing processor=${processor} or resourceId=${resourceId}`);
62
+ return assistant.respond('Subscription payment details not found', { code: 400 });
63
+ }
64
+
65
+ // Load the processor module
66
+ let processorModule;
67
+ try {
68
+ processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
69
+ } catch (e) {
70
+ return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
71
+ }
72
+
73
+ // Process the refund via the processor
74
+ let refund;
75
+ try {
76
+ refund = await processorModule.processRefund({ resourceId, uid, subscription, assistant });
77
+ } catch (e) {
78
+ assistant.log(`Failed to process refund via ${processor}: ${e.message}`);
79
+ return assistant.respond(`Failed to process refund: ${e.message}`, { code: 500, sentry: true });
80
+ }
81
+
82
+ assistant.log(`Refund processed: uid=${uid}, processor=${processor}, sub=${resourceId}, amount=${refund.amount}, full=${refund.full}, reason=${settings.reason}`);
83
+
84
+ return assistant.respond({ success: true, refund });
85
+ };