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,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;