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
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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.
|
|
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: {
|
|
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: {
|
|
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.
|
|
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
|
|
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,
|
|
28
|
-
state.productB = { id: productB.id, name: productB.name,
|
|
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
|
|
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: {
|
|
78
|
+
plan: { product: state.productB.stripeProductId, interval: 'month' },
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
81
|
});
|