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
@@ -0,0 +1,117 @@
1
+ /**
2
+ * PayPal refund processor
3
+ * Refunds the most recent payment on a PayPal subscription and cancels it.
4
+ *
5
+ * PayPal refunds are issued against individual sale/capture transactions,
6
+ * not against the subscription itself. We find the most recent completed
7
+ * transaction and refund it.
8
+ */
9
+ const FULL_REFUND_DAYS = 7;
10
+
11
+ module.exports = {
12
+ /**
13
+ * Process a refund for a PayPal subscription
14
+ *
15
+ * @param {object} options
16
+ * @param {string} options.resourceId - PayPal subscription ID (e.g., 'I-xxx')
17
+ * @param {string} options.uid - User's UID (for logging)
18
+ * @param {object} options.assistant - Assistant instance for logging
19
+ * @returns {{ amount: number, currency: string, full: boolean }}
20
+ */
21
+ async processRefund({ resourceId, uid, assistant }) {
22
+ const PayPalLib = require('../../../../libraries/payment/processors/paypal.js');
23
+
24
+ // 1. Get subscription transactions to find the latest payment
25
+ const now = new Date();
26
+ const oneYearAgo = new Date(now);
27
+ oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
28
+
29
+ const transactions = await PayPalLib.request(
30
+ `/v1/billing/subscriptions/${resourceId}/transactions?start_time=${oneYearAgo.toISOString()}&end_time=${now.toISOString()}`
31
+ );
32
+
33
+ const completedTransactions = (transactions.transactions || [])
34
+ .filter(t => t.status === 'COMPLETED')
35
+ .sort((a, b) => new Date(b.time) - new Date(a.time));
36
+
37
+ if (completedTransactions.length === 0) {
38
+ throw new Error('No completed transactions found for this subscription');
39
+ }
40
+
41
+ const latestTransaction = completedTransactions[0];
42
+ const saleId = latestTransaction.id;
43
+ const transactionAmount = parseFloat(latestTransaction.amount_with_breakdown?.gross_amount?.value || '0');
44
+ const currency = latestTransaction.amount_with_breakdown?.gross_amount?.currency_code || 'USD';
45
+
46
+ if (transactionAmount <= 0) {
47
+ throw new Error('No refundable amount on the latest transaction');
48
+ }
49
+
50
+ // 2. Calculate refund amount
51
+ const transactionDate = new Date(latestTransaction.time);
52
+ const daysSincePayment = (now - transactionDate) / (1000 * 60 * 60 * 24);
53
+
54
+ let refundAmount;
55
+ let isFullRefund;
56
+
57
+ if (daysSincePayment <= FULL_REFUND_DAYS) {
58
+ refundAmount = transactionAmount;
59
+ isFullRefund = true;
60
+ } else {
61
+ // Prorated refund — estimate based on billing cycle
62
+ // PayPal doesn't expose period start/end per transaction like Stripe
63
+ // Approximate: 30 days for monthly, 365 for yearly
64
+ const sub = await PayPalLib.request(`/v1/billing/subscriptions/${resourceId}`);
65
+ const nextBilling = sub.billing_info?.next_billing_time
66
+ ? new Date(sub.billing_info.next_billing_time)
67
+ : null;
68
+
69
+ if (nextBilling) {
70
+ const totalDays = (nextBilling - transactionDate) / (1000 * 60 * 60 * 24);
71
+ const daysRemaining = Math.max(0, (nextBilling - now) / (1000 * 60 * 60 * 24));
72
+ refundAmount = Math.round((daysRemaining / totalDays) * transactionAmount * 100) / 100;
73
+ } else {
74
+ // Fallback: half refund
75
+ refundAmount = Math.round(transactionAmount * 50) / 100;
76
+ }
77
+
78
+ isFullRefund = false;
79
+ }
80
+
81
+ if (refundAmount <= 0) {
82
+ throw new Error('No refundable amount remaining');
83
+ }
84
+
85
+ // 3. Issue the refund against the sale/capture
86
+ await PayPalLib.request(`/v2/payments/captures/${saleId}/refund`, {
87
+ method: 'POST',
88
+ body: JSON.stringify({
89
+ amount: {
90
+ value: refundAmount.toFixed(2),
91
+ currency_code: currency,
92
+ },
93
+ note_to_payer: 'Subscription refund',
94
+ }),
95
+ });
96
+
97
+ assistant.log(`PayPal refund issued: saleId=${saleId}, amount=${refundAmount}, full=${isFullRefund}, uid=${uid}`);
98
+
99
+ // 4. Cancel the subscription
100
+ try {
101
+ await PayPalLib.request(`/v1/billing/subscriptions/${resourceId}/cancel`, {
102
+ method: 'POST',
103
+ body: JSON.stringify({ reason: 'Refund requested' }),
104
+ });
105
+ assistant.log(`PayPal subscription cancelled after refund: sub=${resourceId}, uid=${uid}`);
106
+ } catch (e) {
107
+ // Already cancelled — that's fine
108
+ assistant.log(`PayPal subscription cancel after refund failed (may already be cancelled): ${e.message}`);
109
+ }
110
+
111
+ return {
112
+ amount: refundAmount,
113
+ currency: currency.toLowerCase(),
114
+ full: isFullRefund,
115
+ };
116
+ },
117
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Stripe refund processor
3
+ * Issues a refund for the latest invoice and cancels the subscription immediately.
4
+ *
5
+ * Refund amount:
6
+ * - Full refund if the last payment was ≤7 days ago
7
+ * - Prorated refund (based on days remaining in billing period) if >7 days ago
8
+ *
9
+ * After refunding, if the subscription is still active, it is cancelled immediately.
10
+ * Stripe then sends a customer.subscription.deleted webhook which the existing
11
+ * pipeline processes to update Firestore.
12
+ */
13
+ const FULL_REFUND_DAYS = 7;
14
+
15
+ module.exports = {
16
+ /**
17
+ * Process a refund for a Stripe subscription
18
+ *
19
+ * @param {object} options
20
+ * @param {string} options.resourceId - Stripe subscription ID (e.g., 'sub_xxx')
21
+ * @param {string} options.uid - User's UID (for logging)
22
+ * @param {object} options.subscription - User's subscription object from Firestore
23
+ * @param {object} options.assistant - Assistant instance for logging
24
+ * @returns {{ amount: number, currency: string, full: boolean }}
25
+ */
26
+ async processRefund({ resourceId, uid, assistant }) {
27
+ const StripeLib = require('../../../../libraries/payment/processors/stripe.js');
28
+ const stripe = StripeLib.init();
29
+
30
+ // 1. Retrieve subscription to get latest_invoice
31
+ const sub = await stripe.subscriptions.retrieve(resourceId);
32
+
33
+ if (!sub.latest_invoice) {
34
+ throw new Error('No invoice found for this subscription');
35
+ }
36
+
37
+ // 2. Retrieve the latest invoice to get payment_intent + timing
38
+ const invoiceId = typeof sub.latest_invoice === 'string'
39
+ ? sub.latest_invoice
40
+ : sub.latest_invoice.id;
41
+ const invoice = await stripe.invoices.retrieve(invoiceId);
42
+
43
+ if (!invoice.payment_intent) {
44
+ throw new Error('No payment found for the latest invoice');
45
+ }
46
+
47
+ // 3. Calculate refund amount
48
+ const invoicePaidAt = invoice.status_transitions?.paid_at || invoice.created;
49
+ const daysSincePayment = (Date.now() / 1000 - invoicePaidAt) / 86400;
50
+ const invoiceAmount = invoice.amount_paid; // in cents
51
+
52
+ if (invoiceAmount <= 0) {
53
+ throw new Error('No refundable amount on the latest invoice');
54
+ }
55
+
56
+ let refundAmount;
57
+ let isFullRefund;
58
+
59
+ if (daysSincePayment <= FULL_REFUND_DAYS) {
60
+ refundAmount = invoiceAmount;
61
+ isFullRefund = true;
62
+ } else {
63
+ // Prorated: remaining days / total days * amount
64
+ const periodStart = sub.current_period_start || invoice.period_start;
65
+ const periodEnd = sub.current_period_end || invoice.period_end;
66
+ const totalDays = (periodEnd - periodStart) / 86400;
67
+ const daysRemaining = Math.max(0, (periodEnd - Date.now() / 1000) / 86400);
68
+
69
+ refundAmount = Math.round((daysRemaining / totalDays) * invoiceAmount);
70
+ isFullRefund = false;
71
+ }
72
+
73
+ if (refundAmount <= 0) {
74
+ throw new Error('No refundable amount remaining');
75
+ }
76
+
77
+ // 4. Issue the refund
78
+ const paymentIntentId = typeof invoice.payment_intent === 'string'
79
+ ? invoice.payment_intent
80
+ : invoice.payment_intent.id;
81
+
82
+ const refund = await stripe.refunds.create({
83
+ payment_intent: paymentIntentId,
84
+ amount: refundAmount,
85
+ reason: 'requested_by_customer',
86
+ });
87
+
88
+ assistant.log(`Stripe refund created: refundId=${refund.id}, amount=${refundAmount}, full=${isFullRefund}, uid=${uid}`);
89
+
90
+ // 5. Cancel subscription immediately (if not already canceled)
91
+ // This triggers customer.subscription.deleted webhook → existing pipeline
92
+ if (sub.status !== 'canceled') {
93
+ await stripe.subscriptions.cancel(resourceId);
94
+ assistant.log(`Stripe subscription cancelled immediately: sub=${resourceId}, uid=${uid}`);
95
+ }
96
+
97
+ return {
98
+ amount: refundAmount / 100, // convert cents to dollars for response
99
+ currency: refund.currency,
100
+ full: isFullRefund,
101
+ };
102
+ },
103
+ };
@@ -0,0 +1,98 @@
1
+ const powertools = require('node-powertools');
2
+
3
+ /**
4
+ * Test refund processor
5
+ * Simulates a Stripe refund + immediate cancellation by writing directly to
6
+ * payments-webhooks/{eventId} with status=pending.
7
+ * The on-write trigger picks it up and runs the full pipeline,
8
+ * resulting in a subscription-cancelled transition.
9
+ * Only available in non-production environments.
10
+ */
11
+ module.exports = {
12
+ async processRefund({ resourceId, uid, subscription, assistant }) {
13
+ if (assistant.isProduction()) {
14
+ throw new Error('Test processor is not available in production');
15
+ }
16
+
17
+ const admin = assistant.Manager.libraries.admin;
18
+
19
+ const timestamp = Date.now();
20
+ const eventId = `_test-evt-refund-${timestamp}`;
21
+ const now = Math.floor(timestamp / 1000);
22
+
23
+ // Look up the Stripe product ID from the existing order so resolveProduct() can match
24
+ const orderId = subscription?.payment?.orderId;
25
+ let stripeProductId = null;
26
+
27
+ if (orderId) {
28
+ const orderDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
29
+ if (orderDoc.exists) {
30
+ const orderData = orderDoc.data();
31
+ const productId = orderData.unified?.product?.id;
32
+ const products = assistant.Manager.config.payment?.products || [];
33
+ const product = products.find(p => p.id === productId);
34
+ stripeProductId = product?.stripe?.productId || null;
35
+ }
36
+ }
37
+
38
+ // Build a Stripe-shaped customer.subscription.deleted payload
39
+ // Mirrors what Stripe sends after an immediate cancellation (refund + cancel)
40
+ const subscriptionObj = {
41
+ id: resourceId,
42
+ object: 'subscription',
43
+ status: 'canceled',
44
+ metadata: { uid, orderId },
45
+ cancel_at_period_end: false,
46
+ cancel_at: null,
47
+ canceled_at: now,
48
+ current_period_end: now,
49
+ current_period_start: now - (30 * 86400),
50
+ start_date: now - (30 * 86400),
51
+ trial_start: null,
52
+ trial_end: null,
53
+ plan: { product: stripeProductId, interval: 'month' },
54
+ };
55
+
56
+ const nowTs = powertools.timestamp(new Date(), { output: 'string' });
57
+ const nowUNIX = powertools.timestamp(nowTs, { output: 'unix' });
58
+
59
+ // Write directly to payments-webhooks — on-write trigger handles the rest
60
+ await admin.firestore().doc(`payments-webhooks/${eventId}`).set({
61
+ id: eventId,
62
+ processor: 'test',
63
+ status: 'pending',
64
+ owner: uid,
65
+ raw: {
66
+ id: eventId,
67
+ type: 'customer.subscription.deleted',
68
+ data: { object: subscriptionObj },
69
+ },
70
+ event: {
71
+ type: 'customer.subscription.deleted',
72
+ category: 'subscription',
73
+ resourceType: 'subscription',
74
+ resourceId: resourceId,
75
+ },
76
+ error: null,
77
+ metadata: {
78
+ received: {
79
+ timestamp: nowTs,
80
+ timestampUNIX: nowUNIX,
81
+ },
82
+ processed: {
83
+ timestamp: null,
84
+ timestampUNIX: null,
85
+ },
86
+ },
87
+ });
88
+
89
+ assistant.log(`Test refund processor: wrote payments-webhooks/${eventId} for sub=${resourceId}, uid=${uid}`);
90
+
91
+ // Return mock refund result
92
+ return {
93
+ amount: subscription?.payment?.price || 0,
94
+ currency: 'usd',
95
+ full: true,
96
+ };
97
+ },
98
+ };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * PayPal webhook processor
3
+ * Extracts, validates, and categorizes webhook event data from PayPal
4
+ *
5
+ * PayPal webhook events: https://developer.paypal.com/api/rest/webhooks/event-names/
6
+ */
7
+
8
+ // Events we process, mapped to their category
9
+ const SUPPORTED_EVENTS = new Set([
10
+ // Subscription lifecycle
11
+ 'BILLING.SUBSCRIPTION.ACTIVATED',
12
+ 'BILLING.SUBSCRIPTION.UPDATED',
13
+ 'BILLING.SUBSCRIPTION.CANCELLED',
14
+ 'BILLING.SUBSCRIPTION.SUSPENDED',
15
+ 'BILLING.SUBSCRIPTION.EXPIRED',
16
+ 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
17
+
18
+ // Payment events (subscription billing)
19
+ 'PAYMENT.SALE.COMPLETED',
20
+ 'PAYMENT.SALE.DENIED',
21
+ 'PAYMENT.SALE.REFUNDED',
22
+
23
+ // One-time order events
24
+ 'CHECKOUT.ORDER.APPROVED',
25
+ ]);
26
+
27
+ module.exports = {
28
+ /**
29
+ * Returns true if this event type should be saved and processed
30
+ */
31
+ isSupported(eventType) {
32
+ return SUPPORTED_EVENTS.has(eventType);
33
+ },
34
+
35
+ /**
36
+ * Parse a PayPal webhook request
37
+ * Extracts event data and determines category, resource type, resource ID, and UID
38
+ *
39
+ * @param {object} req - The raw HTTP request
40
+ * @returns {object} { eventId, eventType, category, resourceType, resourceId, raw, uid }
41
+ */
42
+ parseWebhook(req) {
43
+ const event = req.body;
44
+
45
+ // Validate event structure
46
+ if (!event || !event.id || !event.event_type) {
47
+ throw new Error('Invalid PayPal webhook payload');
48
+ }
49
+
50
+ const resource = event.resource || {};
51
+ const eventType = event.event_type;
52
+
53
+ let category = null;
54
+ let resourceType = null;
55
+ let resourceId = null;
56
+ let uid = null;
57
+
58
+ if (eventType.startsWith('BILLING.SUBSCRIPTION.')) {
59
+ // Subscription lifecycle events
60
+ category = 'subscription';
61
+ resourceType = 'subscription';
62
+ resourceId = resource.id; // PayPal subscription ID (I-xxx)
63
+
64
+ // Parse uid from custom_id
65
+ uid = parseUidFromCustomId(resource.custom_id);
66
+
67
+ } else if (eventType === 'PAYMENT.SALE.COMPLETED' || eventType === 'PAYMENT.SALE.DENIED') {
68
+ // Payment sale — determine if it's for a subscription
69
+ const billingAgreementId = resource.billing_agreement_id;
70
+
71
+ if (billingAgreementId) {
72
+ // Subscription payment
73
+ category = 'subscription';
74
+ resourceType = 'subscription';
75
+ resourceId = billingAgreementId; // This is the subscription ID
76
+
77
+ uid = parseUidFromCustomId(resource.custom_id);
78
+ } else {
79
+ // One-time payment — skip for now (not yet supported)
80
+ category = null;
81
+ }
82
+
83
+ } else if (eventType === 'CHECKOUT.ORDER.APPROVED') {
84
+ // One-time order approved by buyer — will be captured in fetchResource
85
+ category = 'one-time';
86
+ resourceType = 'order';
87
+ resourceId = resource.id; // PayPal order ID
88
+
89
+ // Parse uid from purchase_units custom_id
90
+ uid = parseUidFromCustomId(resource.purchase_units?.[0]?.custom_id);
91
+
92
+ } else if (eventType === 'PAYMENT.SALE.REFUNDED') {
93
+ // Refund — linked to a subscription via billing_agreement_id
94
+ const billingAgreementId = resource.billing_agreement_id;
95
+
96
+ if (billingAgreementId) {
97
+ category = 'subscription';
98
+ resourceType = 'subscription';
99
+ resourceId = billingAgreementId;
100
+ uid = parseUidFromCustomId(resource.custom_id);
101
+ } else {
102
+ category = null;
103
+ }
104
+ }
105
+
106
+ return {
107
+ eventId: event.id,
108
+ eventType: eventType,
109
+ category: category,
110
+ resourceType: resourceType,
111
+ resourceId: resourceId,
112
+ raw: event,
113
+ uid: uid,
114
+ };
115
+ },
116
+ };
117
+
118
+ /**
119
+ * Parse uid from PayPal custom_id format: uid:{uid},orderId:{orderId}
120
+ * @param {string} customId
121
+ * @returns {string|null}
122
+ */
123
+ function parseUidFromCustomId(customId) {
124
+ if (!customId) {
125
+ return null;
126
+ }
127
+
128
+ for (const part of customId.split(',')) {
129
+ const [key, ...valueParts] = part.split(':');
130
+
131
+ if (key === 'uid') {
132
+ return valueParts.join(':') || null;
133
+ }
134
+ }
135
+
136
+ return null;
137
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Schema: POST /payments/refund
3
+ * Validates subscription refund parameters
4
+ */
5
+ module.exports = () => ({
6
+ reason: {
7
+ types: ['string'],
8
+ required: true,
9
+ },
10
+ feedback: {
11
+ types: ['string'],
12
+ default: null,
13
+ },
14
+ confirmed: {
15
+ types: ['boolean'],
16
+ required: true,
17
+ },
18
+ });
@@ -285,6 +285,52 @@ const JOURNEY_ACCOUNTS = {
285
285
  subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), payment: { processor: 'unknown-processor', resourceId: 'sub_test_fake' } },
286
286
  },
287
287
  },
288
+ // Dedicated accounts for refund validation tests
289
+ 'refund-active-no-cancel': {
290
+ id: 'refund-active-no-cancel',
291
+ uid: '_test-refund-active-no-cancel',
292
+ email: '_test.refund-active-no-cancel@{domain}',
293
+ properties: {
294
+ roles: {},
295
+ subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake' } },
296
+ },
297
+ },
298
+ 'refund-no-processor': {
299
+ id: 'refund-no-processor',
300
+ uid: '_test-refund-no-processor',
301
+ email: '_test.refund-no-processor@{domain}',
302
+ properties: {
303
+ roles: {},
304
+ subscription: { product: { id: 'premium', name: 'Premium' }, status: 'cancelled', expires: getPastExpires(), cancellation: { pending: false }, payment: { processor: null, resourceId: null } },
305
+ },
306
+ },
307
+ 'refund-unknown-processor': {
308
+ id: 'refund-unknown-processor',
309
+ uid: '_test-refund-unknown-processor',
310
+ email: '_test.refund-unknown-processor@{domain}',
311
+ properties: {
312
+ roles: {},
313
+ subscription: { product: { id: 'premium', name: 'Premium' }, status: 'cancelled', expires: getPastExpires(), cancellation: { pending: false }, payment: { processor: 'unknown-processor', resourceId: 'sub_test_fake' } },
314
+ },
315
+ },
316
+ 'refund-expired-payment': {
317
+ id: 'refund-expired-payment',
318
+ uid: '_test-refund-expired-payment',
319
+ email: '_test.refund-expired-payment@{domain}',
320
+ properties: {
321
+ roles: {},
322
+ subscription: { product: { id: 'premium', name: 'Premium' }, status: 'cancelled', expires: getPastExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: getPastExpires() } },
323
+ },
324
+ },
325
+ 'route-refund-success': {
326
+ id: 'route-refund-success',
327
+ uid: '_test-route-refund-success',
328
+ email: '_test.route-refund-success@{domain}',
329
+ properties: {
330
+ roles: {},
331
+ subscription: { product: { id: 'basic' }, status: 'active' },
332
+ },
333
+ },
288
334
  };
289
335
 
290
336
  /**
@@ -60,16 +60,14 @@
60
60
  days: 14,
61
61
  },
62
62
  prices: {
63
- monthly: {
64
- amount: 4.99,
65
- stripe: null,
66
- paypal: null,
67
- },
68
- annually: {
69
- amount: 49.99,
70
- stripe: null,
71
- paypal: null,
72
- },
63
+ monthly: 4.99,
64
+ annually: 49.99,
65
+ },
66
+ stripe: {
67
+ productId: null,
68
+ },
69
+ paypal: {
70
+ productId: null,
73
71
  },
74
72
  },
75
73
  {
@@ -80,16 +78,14 @@
80
78
  requests: 10000,
81
79
  },
82
80
  prices: {
83
- monthly: {
84
- amount: 19.99,
85
- stripe: null,
86
- paypal: null,
87
- },
88
- annually: {
89
- amount: 199.99,
90
- stripe: null,
91
- paypal: null,
92
- },
81
+ monthly: 19.99,
82
+ annually: 199.99,
83
+ },
84
+ stripe: {
85
+ productId: null,
86
+ },
87
+ paypal: {
88
+ productId: null,
93
89
  },
94
90
  },
95
91
  {
@@ -97,10 +93,10 @@
97
93
  name: '100 Credits',
98
94
  type: 'one-time',
99
95
  prices: {
100
- once: {
101
- amount: 9.99,
102
- stripe: null,
103
- },
96
+ once: 9.99,
97
+ },
98
+ stripe: {
99
+ productId: null,
104
100
  },
105
101
  },
106
102
  // Add more products/tiers here
@@ -23,7 +23,7 @@ module.exports = {
23
23
  state.uid = uid;
24
24
  state.paidProductId = paidProduct.id;
25
25
  state.paidProductName = paidProduct.name;
26
- state.paidPriceId = paidProduct.prices.monthly.stripe;
26
+ state.paidStripeProductId = paidProduct.stripe?.productId;
27
27
 
28
28
  // Create subscription via test intent
29
29
  const response = await http.as('journey-payments-cancel').post('payments/intent', {
@@ -74,7 +74,7 @@ module.exports = {
74
74
  start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
75
75
  trial_start: null,
76
76
  trial_end: null,
77
- plan: { id: state.paidPriceId, interval: 'month' },
77
+ plan: { product: state.paidStripeProductId, interval: 'month' },
78
78
  },
79
79
  },
80
80
  });
@@ -122,7 +122,7 @@ module.exports = {
122
122
  start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
123
123
  trial_start: null,
124
124
  trial_end: null,
125
- plan: { id: state.paidPriceId, interval: 'month' },
125
+ plan: { product: state.paidStripeProductId, interval: 'month' },
126
126
  },
127
127
  },
128
128
  });
@@ -26,7 +26,7 @@ module.exports = {
26
26
  state.uid = uid;
27
27
  state.paidProductId = paidProduct.id;
28
28
  state.paidProductName = paidProduct.name;
29
- state.paidPriceId = paidProduct.prices.monthly.stripe;
29
+ state.paidStripeProductId = paidProduct.stripe?.productId;
30
30
 
31
31
  // Create subscription via test intent
32
32
  const response = await http.as('journey-payments-failure').post('payments/intent', {
@@ -29,7 +29,7 @@ module.exports = {
29
29
  state.uid = uid;
30
30
  state.productId = oneTimeProduct.id;
31
31
  state.productName = oneTimeProduct.name;
32
- state.price = oneTimeProduct.prices.once.amount;
32
+ state.price = oneTimeProduct.prices.once;
33
33
 
34
34
  // Snapshot subscription before purchase — should remain unchanged after
35
35
  state.subscriptionBefore = userDoc.subscription || null;
@@ -24,8 +24,8 @@ module.exports = {
24
24
  const productB = paidProducts[1];
25
25
 
26
26
  state.uid = uid;
27
- state.productA = { id: productA.id, name: productA.name, priceId: productA.prices.monthly.stripe };
28
- state.productB = { id: productB.id, name: productB.name, priceId: productB.prices.monthly.stripe };
27
+ state.productA = { id: productA.id, name: productA.name, stripeProductId: productA.stripe?.productId };
28
+ state.productB = { id: productB.id, name: productB.name, stripeProductId: productB.stripe?.productId };
29
29
 
30
30
  // Create subscription via test intent (product A)
31
31
  const response = await http.as('journey-payments-plan-change').post('payments/intent', {
@@ -58,7 +58,7 @@ module.exports = {
58
58
 
59
59
  state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
60
60
 
61
- // Send subscription.updated with a different product's price ID
61
+ // Send subscription.updated with a different product's Stripe product ID
62
62
  const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
63
63
  id: state.eventId,
64
64
  type: 'customer.subscription.updated',
@@ -75,7 +75,7 @@ module.exports = {
75
75
  start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
76
76
  trial_start: null,
77
77
  trial_end: null,
78
- plan: { id: state.productB.priceId, interval: 'month' },
78
+ plan: { product: state.productB.stripeProductId, interval: 'month' },
79
79
  },
80
80
  },
81
81
  });