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,587 @@
|
|
|
1
|
+
const powertools = require('node-powertools');
|
|
2
|
+
|
|
3
|
+
// Epoch zero timestamps (used as default/empty dates)
|
|
4
|
+
const EPOCH_ZERO = powertools.timestamp(new Date(0), { output: 'string' });
|
|
5
|
+
const EPOCH_ZERO_UNIX = powertools.timestamp(EPOCH_ZERO, { output: 'unix' });
|
|
6
|
+
|
|
7
|
+
// PayPal interval → unified frequency map
|
|
8
|
+
const INTERVAL_TO_FREQUENCY = { YEAR: 'annually', MONTH: 'monthly', WEEK: 'weekly', DAY: 'daily' };
|
|
9
|
+
|
|
10
|
+
// PayPal API base URL
|
|
11
|
+
const PAYPAL_API_BASE = 'https://api-m.paypal.com';
|
|
12
|
+
|
|
13
|
+
// Cached access token + expiry
|
|
14
|
+
let cachedToken = null;
|
|
15
|
+
let tokenExpiresAt = 0;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* PayPal shared library
|
|
19
|
+
* Provides API helpers, resource fetching, and unified transformations
|
|
20
|
+
*/
|
|
21
|
+
const PayPal = {
|
|
22
|
+
/**
|
|
23
|
+
* Initialize or return a PayPal access token
|
|
24
|
+
* Uses client credentials grant (client_id + secret)
|
|
25
|
+
* @returns {Promise<string>} Access token
|
|
26
|
+
*/
|
|
27
|
+
async init() {
|
|
28
|
+
// Return cached token if still valid (with 60s buffer)
|
|
29
|
+
if (cachedToken && Date.now() < tokenExpiresAt - 60000) {
|
|
30
|
+
return cachedToken;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const clientId = process.env.PAYPAL_CLIENT_ID;
|
|
34
|
+
const clientSecret = process.env.PAYPAL_CLIENT_SECRET;
|
|
35
|
+
|
|
36
|
+
if (!clientId || !clientSecret) {
|
|
37
|
+
throw new Error('PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables are required');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
41
|
+
|
|
42
|
+
const response = await fetch(`${PAYPAL_API_BASE}/v1/oauth2/token`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
'Authorization': `Basic ${auth}`,
|
|
46
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
47
|
+
},
|
|
48
|
+
body: 'grant_type=client_credentials',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`PayPal auth failed: ${response.status} ${response.statusText}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const data = await response.json();
|
|
56
|
+
cachedToken = data.access_token;
|
|
57
|
+
tokenExpiresAt = Date.now() + (data.expires_in * 1000);
|
|
58
|
+
|
|
59
|
+
return cachedToken;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Make an authenticated PayPal API request
|
|
64
|
+
* @param {string} endpoint - API path (e.g., '/v1/billing/subscriptions/I-xxx')
|
|
65
|
+
* @param {object} options - fetch options (method, body, etc.)
|
|
66
|
+
* @returns {Promise<object>} Parsed JSON response
|
|
67
|
+
*/
|
|
68
|
+
async request(endpoint, options = {}) {
|
|
69
|
+
const token = await this.init();
|
|
70
|
+
|
|
71
|
+
const response = await fetch(`${PAYPAL_API_BASE}${endpoint}`, {
|
|
72
|
+
...options,
|
|
73
|
+
headers: {
|
|
74
|
+
'Authorization': `Bearer ${token}`,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
...options.headers,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 204 No Content
|
|
81
|
+
if (response.status === 204) {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const data = await response.json();
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const msg = data.message || data.error_description || JSON.stringify(data);
|
|
89
|
+
throw new Error(`PayPal API ${response.status}: ${msg}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return data;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch the latest resource from PayPal's API
|
|
97
|
+
* Falls back to the raw webhook payload if the API call fails
|
|
98
|
+
*
|
|
99
|
+
* For orders: captures the payment first (moves funds), then returns the captured order
|
|
100
|
+
*
|
|
101
|
+
* @param {string} resourceType - 'subscription' or 'order'
|
|
102
|
+
* @param {string} resourceId - PayPal resource ID (e.g., 'I-xxx' or order ID)
|
|
103
|
+
* @param {object} rawFallback - Fallback data from webhook payload
|
|
104
|
+
* @param {object} context - Additional context (e.g., { config })
|
|
105
|
+
* @returns {object} Full PayPal resource object
|
|
106
|
+
*/
|
|
107
|
+
async fetchResource(resourceType, resourceId, rawFallback, context) {
|
|
108
|
+
try {
|
|
109
|
+
if (resourceType === 'subscription') {
|
|
110
|
+
const sub = await this.request(`/v1/billing/subscriptions/${resourceId}`);
|
|
111
|
+
|
|
112
|
+
// Fetch the plan to get product_id (subscription doesn't include it)
|
|
113
|
+
if (sub.plan_id) {
|
|
114
|
+
try {
|
|
115
|
+
const plan = await this.request(`/v1/billing/plans/${sub.plan_id}`);
|
|
116
|
+
sub._plan = plan;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// Plan fetch failed — continue without it
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return sub;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (resourceType === 'order') {
|
|
126
|
+
// Capture the order to move funds, then return the captured state
|
|
127
|
+
const captured = await this.request(`/v2/checkout/orders/${resourceId}/capture`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return captured;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new Error(`Unknown resource type: ${resourceType}`);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// If the API call fails but we have raw webhook data, use it
|
|
137
|
+
if (rawFallback && Object.keys(rawFallback).length > 0) {
|
|
138
|
+
return rawFallback;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Transform a raw PayPal subscription object into the unified subscription shape
|
|
147
|
+
*
|
|
148
|
+
* @param {object} rawSubscription - Raw PayPal subscription object (with _plan attached)
|
|
149
|
+
* @param {object} options
|
|
150
|
+
* @param {object} options.config - BEM config (must contain products array)
|
|
151
|
+
* @param {string} options.eventName - Name of the webhook event
|
|
152
|
+
* @param {string} options.eventId - ID of the webhook event
|
|
153
|
+
* @returns {object} Unified subscription object
|
|
154
|
+
*/
|
|
155
|
+
toUnifiedSubscription(rawSubscription, options) {
|
|
156
|
+
options = options || {};
|
|
157
|
+
const config = options.config || {};
|
|
158
|
+
|
|
159
|
+
const status = resolveStatus(rawSubscription);
|
|
160
|
+
const cancellation = resolveCancellation(rawSubscription);
|
|
161
|
+
const trial = resolveTrial(rawSubscription);
|
|
162
|
+
const frequency = resolveFrequency(rawSubscription);
|
|
163
|
+
const product = resolveProduct(rawSubscription, config);
|
|
164
|
+
const expires = resolveExpires(rawSubscription);
|
|
165
|
+
const startDate = resolveStartDate(rawSubscription);
|
|
166
|
+
const price = resolvePrice(product.id, frequency, config);
|
|
167
|
+
|
|
168
|
+
// Parse custom_id for uid and orderId
|
|
169
|
+
const customData = parseCustomId(rawSubscription.custom_id);
|
|
170
|
+
|
|
171
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
172
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
product: product,
|
|
176
|
+
status: status,
|
|
177
|
+
expires: expires,
|
|
178
|
+
trial: trial,
|
|
179
|
+
cancellation: cancellation,
|
|
180
|
+
payment: {
|
|
181
|
+
processor: 'paypal',
|
|
182
|
+
orderId: customData.orderId || null,
|
|
183
|
+
resourceId: rawSubscription.id || null,
|
|
184
|
+
frequency: frequency,
|
|
185
|
+
price: price,
|
|
186
|
+
startDate: startDate,
|
|
187
|
+
updatedBy: {
|
|
188
|
+
event: {
|
|
189
|
+
name: options.eventName || null,
|
|
190
|
+
id: options.eventId || null,
|
|
191
|
+
},
|
|
192
|
+
date: {
|
|
193
|
+
timestamp: now,
|
|
194
|
+
timestampUNIX: nowUNIX,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Transform a raw PayPal one-time payment resource into a unified shape
|
|
203
|
+
*
|
|
204
|
+
* @param {object} rawResource - Raw PayPal resource (capture, order, etc.)
|
|
205
|
+
* @param {object} options
|
|
206
|
+
* @returns {object} Unified one-time payment object
|
|
207
|
+
*/
|
|
208
|
+
toUnifiedOneTime(rawResource, options) {
|
|
209
|
+
options = options || {};
|
|
210
|
+
const config = options.config || {};
|
|
211
|
+
|
|
212
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
213
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
214
|
+
|
|
215
|
+
// Resolve product from purchase_units custom_id (orders) or top-level custom_id (subscriptions)
|
|
216
|
+
const purchaseCustomId = rawResource.purchase_units?.[0]?.custom_id;
|
|
217
|
+
const customData = parseCustomId(purchaseCustomId || rawResource.custom_id);
|
|
218
|
+
const productId = customData.productId;
|
|
219
|
+
const product = resolveProductOneTime(productId, config);
|
|
220
|
+
const price = resolvePrice(productId, 'once', config);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
product: product,
|
|
224
|
+
status: rawResource.status === 'COMPLETED' ? 'complete' : rawResource.status?.toLowerCase() || 'unknown',
|
|
225
|
+
payment: {
|
|
226
|
+
processor: 'paypal',
|
|
227
|
+
orderId: customData.orderId || null,
|
|
228
|
+
resourceId: rawResource.id || null,
|
|
229
|
+
price: price,
|
|
230
|
+
updatedBy: {
|
|
231
|
+
event: {
|
|
232
|
+
name: options.eventName || null,
|
|
233
|
+
id: options.eventId || null,
|
|
234
|
+
},
|
|
235
|
+
date: {
|
|
236
|
+
timestamp: now,
|
|
237
|
+
timestampUNIX: nowUNIX,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve a PayPal plan ID from product config at runtime
|
|
246
|
+
* Fetches plans for the PayPal product ID and matches by interval + amount
|
|
247
|
+
*
|
|
248
|
+
* @param {object} product - Product from config
|
|
249
|
+
* @param {string} frequency - 'monthly', 'annually', etc.
|
|
250
|
+
* @returns {Promise<string>} PayPal plan ID
|
|
251
|
+
*/
|
|
252
|
+
async resolvePlanId(product, frequency) {
|
|
253
|
+
if (product.archived) {
|
|
254
|
+
throw new Error(`Product ${product.id} is archived`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const paypalProductId = product.paypal?.productId;
|
|
258
|
+
|
|
259
|
+
if (!paypalProductId) {
|
|
260
|
+
throw new Error(`No PayPal product ID for ${product.id}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const expectedAmount = product.prices?.[frequency];
|
|
264
|
+
|
|
265
|
+
if (!expectedAmount) {
|
|
266
|
+
throw new Error(`No price configured for ${product.id}/${frequency}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fetch plans for this PayPal product
|
|
270
|
+
const response = await this.request(`/v1/billing/plans?product_id=${paypalProductId}&page_size=20&total_required=true`);
|
|
271
|
+
const plans = response.plans || [];
|
|
272
|
+
|
|
273
|
+
// Map frequency to PayPal interval unit
|
|
274
|
+
const intervalUnit = frequency === 'annually' ? 'YEAR' : 'MONTH';
|
|
275
|
+
|
|
276
|
+
// Find matching active plan by interval + amount
|
|
277
|
+
for (const plan of plans) {
|
|
278
|
+
if (plan.status !== 'ACTIVE') {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const cycle = plan.billing_cycles?.find(c => c.tenure_type === 'REGULAR');
|
|
283
|
+
|
|
284
|
+
if (!cycle) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const planInterval = cycle.frequency?.interval_unit;
|
|
289
|
+
const planAmount = parseFloat(cycle.pricing_scheme?.fixed_price?.value || '0');
|
|
290
|
+
|
|
291
|
+
if (planInterval === intervalUnit && planAmount === expectedAmount) {
|
|
292
|
+
return plan.id;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
throw new Error(`No active PayPal plan for ${product.id}/${frequency} at $${expectedAmount} (product: ${paypalProductId})`);
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Extract the internal orderId from a PayPal resource
|
|
301
|
+
* Stripe stores orderId in resource.metadata.orderId, but PayPal stores it in custom_id
|
|
302
|
+
*
|
|
303
|
+
* @param {object} resource - Raw PayPal resource (subscription or order)
|
|
304
|
+
* @returns {string|null}
|
|
305
|
+
*/
|
|
306
|
+
getOrderId(resource) {
|
|
307
|
+
const purchaseCustomId = resource.purchase_units?.[0]?.custom_id;
|
|
308
|
+
const customData = parseCustomId(purchaseCustomId || resource.custom_id);
|
|
309
|
+
return customData.orderId || null;
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Build the custom_id string for PayPal subscriptions and orders
|
|
314
|
+
* Format: uid:{uid},orderId:{orderId} or uid:{uid},orderId:{orderId},productId:{productId}
|
|
315
|
+
*
|
|
316
|
+
* @param {string} uid - User's Firebase UID
|
|
317
|
+
* @param {string} orderId - Our internal order ID
|
|
318
|
+
* @param {string} [productId] - Product ID (used for one-time payments)
|
|
319
|
+
* @returns {string}
|
|
320
|
+
*/
|
|
321
|
+
buildCustomId(uid, orderId, productId) {
|
|
322
|
+
let customId = `uid:${uid},orderId:${orderId}`;
|
|
323
|
+
|
|
324
|
+
if (productId) {
|
|
325
|
+
customId += `,productId:${productId}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return customId;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Parse the custom_id string from a PayPal subscription
|
|
334
|
+
* Format: uid:{uid},orderId:{orderId}
|
|
335
|
+
*
|
|
336
|
+
* @param {string} customId - The custom_id string
|
|
337
|
+
* @returns {{ uid: string|null, orderId: string|null, productId: string|null }}
|
|
338
|
+
*/
|
|
339
|
+
function parseCustomId(customId) {
|
|
340
|
+
if (!customId) {
|
|
341
|
+
return { uid: null, orderId: null, productId: null };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const result = { uid: null, orderId: null, productId: null };
|
|
345
|
+
|
|
346
|
+
for (const part of customId.split(',')) {
|
|
347
|
+
const [key, ...valueParts] = part.split(':');
|
|
348
|
+
const value = valueParts.join(':'); // Handle values that contain colons
|
|
349
|
+
|
|
350
|
+
if (key === 'uid') {
|
|
351
|
+
result.uid = value || null;
|
|
352
|
+
} else if (key === 'orderId') {
|
|
353
|
+
result.orderId = value || null;
|
|
354
|
+
} else if (key === 'productId') {
|
|
355
|
+
result.productId = value || null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Map PayPal subscription status to unified status
|
|
364
|
+
*
|
|
365
|
+
* | PayPal Status | Unified Status |
|
|
366
|
+
* |------------------|----------------|
|
|
367
|
+
* | ACTIVE | active |
|
|
368
|
+
* | SUSPENDED | suspended |
|
|
369
|
+
* | CANCELLED | cancelled |
|
|
370
|
+
* | EXPIRED | cancelled |
|
|
371
|
+
* | APPROVAL_PENDING | cancelled |
|
|
372
|
+
* | APPROVED | active |
|
|
373
|
+
*/
|
|
374
|
+
function resolveStatus(raw) {
|
|
375
|
+
const status = raw.status;
|
|
376
|
+
|
|
377
|
+
if (status === 'ACTIVE' || status === 'APPROVED') {
|
|
378
|
+
return 'active';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (status === 'SUSPENDED') {
|
|
382
|
+
return 'suspended';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// CANCELLED, EXPIRED, APPROVAL_PENDING, or anything else
|
|
386
|
+
return 'cancelled';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Resolve cancellation state from PayPal subscription
|
|
391
|
+
*/
|
|
392
|
+
function resolveCancellation(raw) {
|
|
393
|
+
if (raw.status === 'CANCELLED') {
|
|
394
|
+
// PayPal doesn't give a specific cancellation date on the sub itself
|
|
395
|
+
// Use status_update_time if available
|
|
396
|
+
const cancelDate = raw.status_update_time
|
|
397
|
+
? powertools.timestamp(new Date(raw.status_update_time), { output: 'string' })
|
|
398
|
+
: EPOCH_ZERO;
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
pending: false,
|
|
402
|
+
date: {
|
|
403
|
+
timestamp: cancelDate,
|
|
404
|
+
timestampUNIX: cancelDate !== EPOCH_ZERO
|
|
405
|
+
? powertools.timestamp(cancelDate, { output: 'unix' })
|
|
406
|
+
: EPOCH_ZERO_UNIX,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
pending: false,
|
|
413
|
+
date: {
|
|
414
|
+
timestamp: EPOCH_ZERO,
|
|
415
|
+
timestampUNIX: EPOCH_ZERO_UNIX,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Resolve trial state from PayPal subscription
|
|
422
|
+
* PayPal trials are represented as billing_cycles with tenure_type === 'TRIAL'
|
|
423
|
+
*/
|
|
424
|
+
function resolveTrial(raw) {
|
|
425
|
+
// Check if the plan has a trial cycle
|
|
426
|
+
const plan = raw._plan || {};
|
|
427
|
+
const trialCycle = plan.billing_cycles?.find(c => c.tenure_type === 'TRIAL');
|
|
428
|
+
|
|
429
|
+
if (!trialCycle) {
|
|
430
|
+
return {
|
|
431
|
+
claimed: false,
|
|
432
|
+
expires: { timestamp: EPOCH_ZERO, timestampUNIX: EPOCH_ZERO_UNIX },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// PayPal doesn't expose exact trial start/end dates on the subscription
|
|
437
|
+
// We can calculate from start_time + trial duration
|
|
438
|
+
const startTime = raw.start_time ? new Date(raw.start_time) : null;
|
|
439
|
+
|
|
440
|
+
if (!startTime) {
|
|
441
|
+
return {
|
|
442
|
+
claimed: true,
|
|
443
|
+
expires: { timestamp: EPOCH_ZERO, timestampUNIX: EPOCH_ZERO_UNIX },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Calculate trial end based on trial cycle frequency
|
|
448
|
+
const trialFreq = trialCycle.frequency;
|
|
449
|
+
const trialCount = trialCycle.total_cycles || 1;
|
|
450
|
+
const trialEnd = new Date(startTime);
|
|
451
|
+
|
|
452
|
+
if (trialFreq?.interval_unit === 'DAY') {
|
|
453
|
+
trialEnd.setDate(trialEnd.getDate() + (trialFreq.interval_count || 1) * trialCount);
|
|
454
|
+
} else if (trialFreq?.interval_unit === 'MONTH') {
|
|
455
|
+
trialEnd.setMonth(trialEnd.getMonth() + (trialFreq.interval_count || 1) * trialCount);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const trialEndStr = powertools.timestamp(trialEnd, { output: 'string' });
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
claimed: true,
|
|
462
|
+
expires: {
|
|
463
|
+
timestamp: trialEndStr,
|
|
464
|
+
timestampUNIX: powertools.timestamp(trialEndStr, { output: 'unix' }),
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Resolve billing frequency from PayPal subscription
|
|
471
|
+
*/
|
|
472
|
+
function resolveFrequency(raw) {
|
|
473
|
+
// Try _plan first (fetched separately)
|
|
474
|
+
const plan = raw._plan || {};
|
|
475
|
+
const regularCycle = plan.billing_cycles?.find(c => c.tenure_type === 'REGULAR');
|
|
476
|
+
|
|
477
|
+
if (regularCycle?.frequency?.interval_unit) {
|
|
478
|
+
return INTERVAL_TO_FREQUENCY[regularCycle.frequency.interval_unit] || null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Fallback: try inline plan info from ?fields=plan
|
|
482
|
+
const inlinePlan = raw.plan;
|
|
483
|
+
if (inlinePlan?.billing_cycles) {
|
|
484
|
+
const cycle = inlinePlan.billing_cycles.find(c => c.tenure_type === 'REGULAR');
|
|
485
|
+
if (cycle?.frequency?.interval_unit) {
|
|
486
|
+
return INTERVAL_TO_FREQUENCY[cycle.frequency.interval_unit] || null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Resolve product by matching the PayPal product ID against config products
|
|
495
|
+
* Uses: sub._plan.product_id → match config product.paypal.productId
|
|
496
|
+
*/
|
|
497
|
+
function resolveProduct(raw, config) {
|
|
498
|
+
// Get PayPal product ID from the plan (attached during fetchResource)
|
|
499
|
+
const paypalProductId = raw._plan?.product_id || null;
|
|
500
|
+
|
|
501
|
+
if (!paypalProductId || !config.payment?.products) {
|
|
502
|
+
return { id: 'basic', name: 'Basic' };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const product of config.payment.products) {
|
|
506
|
+
if (product.paypal?.productId === paypalProductId) {
|
|
507
|
+
return { id: product.id, name: product.name || product.id };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { id: 'basic', name: 'Basic' };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Resolve product for one-time payments
|
|
516
|
+
*/
|
|
517
|
+
function resolveProductOneTime(productId, config) {
|
|
518
|
+
if (!productId || !config.payment?.products) {
|
|
519
|
+
return { id: productId || 'unknown', name: 'Unknown' };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const product = config.payment.products.find(p => p.id === productId);
|
|
523
|
+
|
|
524
|
+
if (!product) {
|
|
525
|
+
return { id: productId, name: productId };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return { id: product.id, name: product.name || product.id };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Resolve subscription expiration from PayPal data
|
|
533
|
+
*/
|
|
534
|
+
function resolveExpires(raw) {
|
|
535
|
+
// PayPal's billing_info.next_billing_time is the closest to "period end"
|
|
536
|
+
const nextBilling = raw.billing_info?.next_billing_time;
|
|
537
|
+
|
|
538
|
+
if (!nextBilling) {
|
|
539
|
+
return {
|
|
540
|
+
timestamp: EPOCH_ZERO,
|
|
541
|
+
timestampUNIX: EPOCH_ZERO_UNIX,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const expiresDate = powertools.timestamp(new Date(nextBilling), { output: 'string' });
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
timestamp: expiresDate,
|
|
549
|
+
timestampUNIX: powertools.timestamp(expiresDate, { output: 'unix' }),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Resolve subscription start date from PayPal data
|
|
555
|
+
*/
|
|
556
|
+
function resolveStartDate(raw) {
|
|
557
|
+
const startTime = raw.start_time || raw.create_time;
|
|
558
|
+
|
|
559
|
+
if (!startTime) {
|
|
560
|
+
return {
|
|
561
|
+
timestamp: EPOCH_ZERO,
|
|
562
|
+
timestampUNIX: EPOCH_ZERO_UNIX,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const startDate = powertools.timestamp(new Date(startTime), { output: 'string' });
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
timestamp: startDate,
|
|
570
|
+
timestampUNIX: powertools.timestamp(startDate, { output: 'unix' }),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Resolve the display price for a product/frequency from config
|
|
576
|
+
*/
|
|
577
|
+
function resolvePrice(productId, frequency, config) {
|
|
578
|
+
const product = config.payment?.products?.find(p => p.id === productId);
|
|
579
|
+
|
|
580
|
+
if (!product || !product.prices) {
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return product.prices[frequency] || 0;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
module.exports = PayPal;
|