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.
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +113 -24
- package/README.md +8 -0
- package/TODO-PAYMENT-v2.md +5 -2
- package/package.json +1 -1
- package/src/cli/commands/deploy.js +2 -4
- package/src/cli/commands/emulator.js +30 -1
- package/src/cli/commands/test.js +33 -2
- package/src/manager/events/firestore/payments-webhooks/on-write.js +17 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/index.js +6 -0
- package/src/manager/libraries/payment/processors/paypal.js +587 -0
- package/src/manager/libraries/{payment-processors → payment/processors}/stripe.js +86 -18
- package/src/manager/libraries/{payment-processors → payment/processors}/test.js +15 -8
- package/src/manager/routes/payments/cancel/processors/paypal.js +30 -0
- package/src/manager/routes/payments/cancel/processors/stripe.js +1 -1
- package/src/manager/routes/payments/cancel/processors/test.js +4 -6
- package/src/manager/routes/payments/intent/post.js +3 -3
- package/src/manager/routes/payments/intent/processors/paypal.js +150 -0
- package/src/manager/routes/payments/intent/processors/stripe.js +3 -5
- package/src/manager/routes/payments/intent/processors/test.js +7 -8
- package/src/manager/routes/payments/portal/processors/paypal.js +24 -0
- package/src/manager/routes/payments/portal/processors/stripe.js +1 -1
- package/src/manager/routes/payments/refund/post.js +85 -0
- package/src/manager/routes/payments/refund/processors/paypal.js +117 -0
- package/src/manager/routes/payments/refund/processors/stripe.js +103 -0
- package/src/manager/routes/payments/refund/processors/test.js +98 -0
- package/src/manager/routes/payments/webhook/processors/paypal.js +137 -0
- package/src/manager/schemas/payments/refund/post.js +18 -0
- package/src/test/test-accounts.js +46 -0
- package/templates/backend-manager-config.json +20 -24
- package/test/events/payments/journey-payments-cancel.js +3 -3
- package/test/events/payments/journey-payments-failure.js +1 -1
- package/test/events/payments/journey-payments-one-time.js +1 -1
- package/test/events/payments/journey-payments-plan-change.js +4 -4
- package/test/events/payments/journey-payments-suspend.js +3 -3
- package/test/events/payments/journey-payments-trial.js +2 -2
- package/test/fixtures/paypal/order-approved.json +62 -0
- package/test/fixtures/paypal/order-completed.json +110 -0
- package/test/fixtures/paypal/subscription-active.json +76 -0
- package/test/fixtures/paypal/subscription-cancelled.json +50 -0
- package/test/fixtures/paypal/subscription-suspended.json +65 -0
- package/test/helpers/payment/paypal/parse-webhook.js +539 -0
- package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
- package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
- package/test/helpers/{stripe-parse-webhook.js → payment/stripe/parse-webhook.js} +4 -4
- package/test/helpers/{stripe-to-unified-one-time.js → payment/stripe/to-unified-one-time.js} +8 -6
- package/test/helpers/{stripe-to-unified.js → payment/stripe/to-unified-subscription.js} +40 -33
- package/test/routes/payments/refund.js +174 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +0 -19
- package/src/manager/routes/forms/delete.js +0 -37
- package/src/manager/routes/forms/get.js +0 -46
- package/src/manager/routes/forms/post.js +0 -45
- package/src/manager/routes/forms/public/get.js +0 -37
- package/src/manager/routes/forms/put.js +0 -52
- package/src/manager/schemas/forms/delete.js +0 -6
- package/src/manager/schemas/forms/get.js +0 -6
- package/src/manager/schemas/forms/post.js +0 -9
- package/src/manager/schemas/forms/public/get.js +0 -6
- package/src/manager/schemas/forms/put.js +0 -10
- /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
|
|
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
|
|
338
|
-
const
|
|
339
|
-
|| raw.
|
|
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 (!
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
134
|
-
* e.g., ('plus'
|
|
140
|
+
* Look up the Stripe product ID from config given a product ID
|
|
141
|
+
* e.g., ('plus') → 'prod_plus'
|
|
135
142
|
*/
|
|
136
|
-
function
|
|
137
|
-
if (!productId || !
|
|
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?.
|
|
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
|
|
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
|
|
23
|
+
// Look up the Stripe product ID from the existing order so resolveProduct() can match
|
|
24
24
|
const orderId = subscription?.payment?.orderId;
|
|
25
|
-
let
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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]
|
|
146
|
-
: (product.prices?.once
|
|
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
|
|
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
|
|
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: {
|
|
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
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|