backend-manager 5.0.104 → 5.0.106

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