backend-manager 5.0.116 → 5.0.118

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 (25) hide show
  1. package/package.json +1 -1
  2. package/src/manager/events/firestore/payments-webhooks/on-write.js +11 -1
  3. package/src/manager/index.js +3 -0
  4. package/src/manager/libraries/payment/processors/chargebee.js +637 -0
  5. package/src/manager/routes/payments/cancel/processors/chargebee.js +43 -0
  6. package/src/manager/routes/payments/cancel/processors/paypal.js +2 -1
  7. package/src/manager/routes/payments/intent/processors/chargebee.js +120 -0
  8. package/src/manager/routes/payments/portal/processors/chargebee.js +62 -0
  9. package/src/manager/routes/payments/portal/processors/paypal.js +2 -1
  10. package/src/manager/routes/payments/refund/processors/chargebee.js +101 -0
  11. package/src/manager/routes/payments/webhook/post.js +8 -0
  12. package/src/manager/routes/payments/webhook/processors/chargebee.js +170 -0
  13. package/templates/backend-manager-config.json +11 -0
  14. package/test/fixtures/chargebee/invoice-one-time.json +27 -0
  15. package/test/fixtures/chargebee/subscription-active.json +44 -0
  16. package/test/fixtures/chargebee/subscription-cancelled.json +42 -0
  17. package/test/fixtures/chargebee/subscription-in-trial.json +41 -0
  18. package/test/fixtures/chargebee/subscription-legacy-plan.json +41 -0
  19. package/test/fixtures/chargebee/subscription-non-renewing.json +41 -0
  20. package/test/fixtures/chargebee/subscription-paused.json +42 -0
  21. package/test/fixtures/chargebee/webhook-payment-failed.json +51 -0
  22. package/test/fixtures/chargebee/webhook-subscription-created.json +47 -0
  23. package/test/helpers/payment/chargebee/parse-webhook.js +413 -0
  24. package/test/helpers/payment/chargebee/to-unified-one-time.js +147 -0
  25. package/test/helpers/payment/chargebee/to-unified-subscription.js +648 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.116",
3
+ "version": "5.0.118",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -321,7 +321,17 @@ function extractCustomerName(resource, resourceType) {
321
321
  }
322
322
  }
323
323
 
324
- // Subscriptions only have customer ID, no name
324
+ // Chargebee subscriptions carry shipping_address / billing_address with first_name + last_name
325
+ if (resourceType === 'subscription') {
326
+ const addr = resource.shipping_address || resource.billing_address;
327
+ if (addr?.first_name) {
328
+ const { capitalize } = require('../../../libraries/infer-contact.js');
329
+ return {
330
+ first: capitalize(addr.first_name) || null,
331
+ last: capitalize(addr.last_name) || null,
332
+ };
333
+ }
334
+ }
325
335
 
326
336
  if (!fullName) {
327
337
  return null;
@@ -141,6 +141,9 @@ Manager.prototype.init = function (exporter, options) {
141
141
  // Set PAYPAL_CLIENT_ID from config (clientId is public, not a secret — lives in config, not .env)
142
142
  process.env.PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || self.config?.payment?.processors?.paypal?.clientId || '';
143
143
 
144
+ // Set CHARGEBEE_SITE from config (site is public, not a secret — lives in config, not .env)
145
+ process.env.CHARGEBEE_SITE = process.env.CHARGEBEE_SITE || self.config?.payment?.processors?.chargebee?.site || '';
146
+
144
147
  // Resolve legacy paths
145
148
  // TODO: Remove this in future versions (after we migrate to removing app.id from config)
146
149
  self.config.app = self.config.app || {};
@@ -0,0 +1,637 @@
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
+ // Chargebee billing_period_unit → unified frequency map
8
+ const UNIT_TO_FREQUENCY = { year: 'annually', month: 'monthly', week: 'weekly', day: 'daily' };
9
+
10
+ // Valid frequency suffixes for deterministic item_price_id parsing
11
+ const VALID_FREQUENCIES = ['monthly', 'annually', 'weekly', 'daily'];
12
+
13
+ // Cached config
14
+ let cachedConfig = null;
15
+
16
+ /**
17
+ * Chargebee shared library
18
+ * Provides API helpers, resource fetching, and unified transformations
19
+ */
20
+ const Chargebee = {
21
+ /**
22
+ * Initialize or return the Chargebee config
23
+ * API key from CHARGEBEE_API_KEY env; site from CHARGEBEE_SITE env (set by Manager from config)
24
+ * @returns {{ apiKey: string, site: string, baseUrl: string }}
25
+ */
26
+ init() {
27
+ if (cachedConfig) {
28
+ return cachedConfig;
29
+ }
30
+
31
+ const apiKey = process.env.CHARGEBEE_API_KEY;
32
+
33
+ if (!apiKey) {
34
+ throw new Error('CHARGEBEE_API_KEY environment variable is required');
35
+ }
36
+
37
+ const site = process.env.CHARGEBEE_SITE;
38
+
39
+ if (!site) {
40
+ throw new Error('CHARGEBEE_SITE environment variable is required (set from config payment.processors.chargebee.site)');
41
+ }
42
+
43
+ cachedConfig = {
44
+ apiKey,
45
+ site,
46
+ baseUrl: `https://${site}.chargebee.com/api/v2`,
47
+ };
48
+
49
+ return cachedConfig;
50
+ },
51
+
52
+ /**
53
+ * Make an authenticated Chargebee API request
54
+ * Chargebee uses Basic auth (apiKey as username, empty password)
55
+ * POST/PUT bodies use application/x-www-form-urlencoded
56
+ * Responses are JSON wrapped in a type key (e.g., { subscription: {...} })
57
+ *
58
+ * @param {string} endpoint - API path (e.g., '/subscriptions/sub_xxx')
59
+ * @param {object} options - { method, body (object to form-encode), headers }
60
+ * @returns {Promise<object>} Parsed JSON response
61
+ */
62
+ async request(endpoint, options = {}) {
63
+ const config = this.init();
64
+ const auth = Buffer.from(`${config.apiKey}:`).toString('base64');
65
+
66
+ const fetchOptions = {
67
+ method: options.method || 'GET',
68
+ headers: {
69
+ 'Authorization': `Basic ${auth}`,
70
+ ...options.headers,
71
+ },
72
+ };
73
+
74
+ // Encode body as form data for POST/PUT
75
+ if (options.body && typeof options.body === 'object') {
76
+ fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
77
+ fetchOptions.body = encodeFormData(options.body);
78
+ } else if (options.body) {
79
+ fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
80
+ fetchOptions.body = options.body;
81
+ }
82
+
83
+ const response = await fetch(`${config.baseUrl}${endpoint}`, fetchOptions);
84
+
85
+ // 204 No Content
86
+ if (response.status === 204) {
87
+ return {};
88
+ }
89
+
90
+ const data = await response.json();
91
+
92
+ if (!response.ok) {
93
+ const msg = data.message || data.error_description || JSON.stringify(data);
94
+ throw new Error(`Chargebee API ${response.status}: ${msg}`);
95
+ }
96
+
97
+ return data;
98
+ },
99
+
100
+ /**
101
+ * Fetch the latest resource from Chargebee's API
102
+ * Falls back to the raw webhook payload if the API call fails
103
+ *
104
+ * @param {string} resourceType - 'subscription' or 'invoice'
105
+ * @param {string} resourceId - Chargebee resource ID
106
+ * @param {object} rawFallback - Fallback data from webhook payload
107
+ * @param {object} context - Additional context
108
+ * @returns {object} Full Chargebee resource object
109
+ */
110
+ async fetchResource(resourceType, resourceId, rawFallback, context) {
111
+ try {
112
+ if (resourceType === 'subscription') {
113
+ const result = await this.request(`/subscriptions/${resourceId}`);
114
+ return result.subscription || result;
115
+ }
116
+
117
+ if (resourceType === 'invoice') {
118
+ const result = await this.request(`/invoices/${resourceId}`);
119
+ return result.invoice || result;
120
+ }
121
+
122
+ throw new Error(`Unknown resource type: ${resourceType}`);
123
+ } catch (e) {
124
+ // If the API call fails but we have raw webhook data, use it
125
+ if (rawFallback && Object.keys(rawFallback).length > 0) {
126
+ return rawFallback;
127
+ }
128
+
129
+ throw e;
130
+ }
131
+ },
132
+
133
+ /**
134
+ * Extract the internal orderId from a Chargebee resource
135
+ * Checks meta_data JSON first (new), then cf_clientorderid (legacy)
136
+ *
137
+ * @param {object} resource - Raw Chargebee resource
138
+ * @returns {string|null}
139
+ */
140
+ getOrderId(resource) {
141
+ const meta = parseMetaData(resource);
142
+
143
+ if (meta.orderId) {
144
+ return meta.orderId;
145
+ }
146
+
147
+ // Legacy: cf_clientorderid custom field
148
+ return resource.cf_clientorderid || null;
149
+ },
150
+
151
+ /**
152
+ * Extract the UID from a Chargebee resource
153
+ * Checks meta_data JSON first (new), then cf_uid (legacy)
154
+ *
155
+ * @param {object} resource - Raw Chargebee resource
156
+ * @returns {string|null}
157
+ */
158
+ getUid(resource) {
159
+ const meta = parseMetaData(resource);
160
+
161
+ if (meta.uid) {
162
+ return meta.uid;
163
+ }
164
+
165
+ // Legacy: cf_uid custom field
166
+ return resource.cf_uid || null;
167
+ },
168
+
169
+ /**
170
+ * Extract refund details from a Chargebee payment_refunded webhook payload
171
+ *
172
+ * @param {object} raw - Raw Chargebee webhook payload
173
+ * @returns {{ amount: string|null, currency: string, reason: string|null }}
174
+ */
175
+ getRefundDetails(raw) {
176
+ const creditNote = raw?.content?.credit_note;
177
+ const transaction = raw?.content?.transaction;
178
+
179
+ // Credit note has the refund amount
180
+ if (creditNote) {
181
+ return {
182
+ amount: creditNote.total ? (creditNote.total / 100).toFixed(2) : null,
183
+ currency: creditNote.currency_code?.toUpperCase() || 'USD',
184
+ reason: creditNote.reason_code || null,
185
+ };
186
+ }
187
+
188
+ // Fall back to transaction
189
+ if (transaction) {
190
+ return {
191
+ amount: transaction.amount ? (transaction.amount / 100).toFixed(2) : null,
192
+ currency: transaction.currency_code?.toUpperCase() || 'USD',
193
+ reason: null,
194
+ };
195
+ }
196
+
197
+ return { amount: null, currency: 'USD', reason: null };
198
+ },
199
+
200
+ /**
201
+ * Transform a raw Chargebee subscription object into the unified subscription shape
202
+ *
203
+ * @param {object} rawSubscription - Raw Chargebee subscription object
204
+ * @param {object} options
205
+ * @param {object} options.config - BEM config (must contain products array)
206
+ * @param {string} options.eventName - Name of the webhook event
207
+ * @param {string} options.eventId - ID of the webhook event
208
+ * @returns {object} Unified subscription object
209
+ */
210
+ toUnifiedSubscription(rawSubscription, options) {
211
+ options = options || {};
212
+ const config = options.config || {};
213
+
214
+ const status = resolveStatus(rawSubscription);
215
+ const cancellation = resolveCancellation(rawSubscription);
216
+ const trial = resolveTrial(rawSubscription);
217
+ const frequency = resolveFrequency(rawSubscription);
218
+ const product = resolveProduct(rawSubscription, config);
219
+ const expires = resolveExpires(rawSubscription);
220
+ const startDate = resolveStartDate(rawSubscription);
221
+ const price = resolvePrice(product.id, frequency, config);
222
+
223
+ const meta = parseMetaData(rawSubscription);
224
+
225
+ const now = powertools.timestamp(new Date(), { output: 'string' });
226
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
227
+
228
+ return {
229
+ product: product,
230
+ status: status,
231
+ expires: expires,
232
+ trial: trial,
233
+ cancellation: cancellation,
234
+ payment: {
235
+ processor: 'chargebee',
236
+ orderId: meta.orderId || rawSubscription.cf_clientorderid || null,
237
+ resourceId: rawSubscription.id || null,
238
+ frequency: frequency,
239
+ price: price,
240
+ startDate: startDate,
241
+ updatedBy: {
242
+ event: {
243
+ name: options.eventName || null,
244
+ id: options.eventId || null,
245
+ },
246
+ date: {
247
+ timestamp: now,
248
+ timestampUNIX: nowUNIX,
249
+ },
250
+ },
251
+ },
252
+ };
253
+ },
254
+
255
+ /**
256
+ * Transform a raw Chargebee one-time payment resource into a unified shape
257
+ * One-time payments come through as non-recurring invoices
258
+ *
259
+ * @param {object} rawResource - Raw Chargebee invoice resource
260
+ * @param {object} options
261
+ * @returns {object} Unified one-time payment object
262
+ */
263
+ toUnifiedOneTime(rawResource, options) {
264
+ options = options || {};
265
+ const config = options.config || {};
266
+
267
+ const now = powertools.timestamp(new Date(), { output: 'string' });
268
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
269
+
270
+ // Try to resolve product from line items or meta_data
271
+ const meta = parseMetaData(rawResource);
272
+ const productId = meta.productId || null;
273
+ const product = resolveProductOneTime(productId, config);
274
+ const price = resolvePrice(productId, 'once', config);
275
+
276
+ // Resolve status from invoice status
277
+ let status = 'unknown';
278
+ if (rawResource.status === 'paid') {
279
+ status = 'completed';
280
+ } else if (rawResource.status === 'payment_due' || rawResource.status === 'not_paid') {
281
+ status = 'failed';
282
+ } else if (rawResource.status) {
283
+ status = rawResource.status;
284
+ }
285
+
286
+ return {
287
+ product: product,
288
+ status: status,
289
+ payment: {
290
+ processor: 'chargebee',
291
+ orderId: meta.orderId || rawResource.cf_clientorderid || null,
292
+ resourceId: rawResource.id || null,
293
+ price: price,
294
+ updatedBy: {
295
+ event: {
296
+ name: options.eventName || null,
297
+ id: options.eventId || null,
298
+ },
299
+ date: {
300
+ timestamp: now,
301
+ timestampUNIX: nowUNIX,
302
+ },
303
+ },
304
+ },
305
+ };
306
+ },
307
+
308
+ /**
309
+ * Build the meta_data JSON string for Chargebee subscriptions
310
+ *
311
+ * @param {string} uid - User's Firebase UID
312
+ * @param {string} orderId - Our internal order ID
313
+ * @param {string} [productId] - Product ID (used for one-time payments)
314
+ * @returns {string} JSON string
315
+ */
316
+ buildMetaData(uid, orderId, productId) {
317
+ const data = { uid, orderId };
318
+
319
+ if (productId) {
320
+ data.productId = productId;
321
+ }
322
+
323
+ return JSON.stringify(data);
324
+ },
325
+ };
326
+
327
+ /**
328
+ * Parse the meta_data JSON from a Chargebee resource
329
+ * Falls back to cf_* custom fields for legacy subscriptions
330
+ *
331
+ * @param {object} resource - Chargebee resource
332
+ * @returns {{ uid: string|null, orderId: string|null, productId: string|null }}
333
+ */
334
+ function parseMetaData(resource) {
335
+ if (!resource) {
336
+ return { uid: null, orderId: null, productId: null };
337
+ }
338
+
339
+ // Try meta_data JSON (new approach)
340
+ const metaData = resource.meta_data;
341
+
342
+ if (metaData) {
343
+ try {
344
+ const parsed = typeof metaData === 'string' ? JSON.parse(metaData) : metaData;
345
+ return {
346
+ uid: parsed.uid || null,
347
+ orderId: parsed.orderId || null,
348
+ productId: parsed.productId || null,
349
+ };
350
+ } catch (e) {
351
+ // Invalid JSON — fall through to legacy
352
+ }
353
+ }
354
+
355
+ // Legacy: cf_* custom fields
356
+ return {
357
+ uid: resource.cf_uid || null,
358
+ orderId: resource.cf_clientorderid || null,
359
+ productId: null,
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Encode an object as application/x-www-form-urlencoded with bracket notation
365
+ * Handles nested objects: { subscription: { plan_id: 'x' } } → subscription[plan_id]=x
366
+ *
367
+ * @param {object} params - Parameters to encode
368
+ * @param {string} [prefix] - Parent key prefix
369
+ * @returns {string} URL-encoded string
370
+ */
371
+ function encodeFormData(params, prefix) {
372
+ const parts = [];
373
+
374
+ for (const [key, value] of Object.entries(params)) {
375
+ const fullKey = prefix ? `${prefix}[${key}]` : key;
376
+
377
+ if (value === null || value === undefined) {
378
+ continue;
379
+ }
380
+
381
+ if (typeof value === 'object' && !Array.isArray(value)) {
382
+ parts.push(encodeFormData(value, fullKey));
383
+ } else if (Array.isArray(value)) {
384
+ for (let i = 0; i < value.length; i++) {
385
+ if (typeof value[i] === 'object') {
386
+ parts.push(encodeFormData(value[i], `${fullKey}[${i}]`));
387
+ } else {
388
+ parts.push(`${encodeURIComponent(`${fullKey}[${i}]`)}=${encodeURIComponent(value[i])}`);
389
+ }
390
+ }
391
+ } else {
392
+ parts.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(value)}`);
393
+ }
394
+ }
395
+
396
+ return parts.filter(Boolean).join('&');
397
+ }
398
+
399
+ /**
400
+ * Map Chargebee subscription status to unified status
401
+ *
402
+ * | Chargebee Status | Unified Status |
403
+ * |------------------|----------------|
404
+ * | active | active |
405
+ * | in_trial | active |
406
+ * | non_renewing | active |
407
+ * | future | active |
408
+ * | paused | suspended |
409
+ * | cancelled | cancelled |
410
+ * | transferred | cancelled |
411
+ */
412
+ function resolveStatus(raw) {
413
+ const status = raw.status;
414
+
415
+ if (status === 'active' || status === 'in_trial' || status === 'non_renewing' || status === 'future') {
416
+ return 'active';
417
+ }
418
+
419
+ if (status === 'paused') {
420
+ return 'suspended';
421
+ }
422
+
423
+ // cancelled, transferred, or anything else
424
+ return 'cancelled';
425
+ }
426
+
427
+ /**
428
+ * Resolve cancellation state from Chargebee subscription
429
+ * non_renewing = pending cancellation (cancel at period end)
430
+ * cancelled + cancelled_at = completed cancellation
431
+ */
432
+ function resolveCancellation(raw) {
433
+ // Pending cancellation: non_renewing status
434
+ if (raw.status === 'non_renewing') {
435
+ const periodEnd = raw.current_term_end
436
+ ? powertools.timestamp(new Date(raw.current_term_end * 1000), { output: 'string' })
437
+ : EPOCH_ZERO;
438
+
439
+ return {
440
+ pending: true,
441
+ date: {
442
+ timestamp: periodEnd,
443
+ timestampUNIX: periodEnd !== EPOCH_ZERO
444
+ ? powertools.timestamp(periodEnd, { output: 'unix' })
445
+ : EPOCH_ZERO_UNIX,
446
+ },
447
+ };
448
+ }
449
+
450
+ // Already cancelled
451
+ if (raw.cancelled_at) {
452
+ const cancelledDate = powertools.timestamp(new Date(raw.cancelled_at * 1000), { output: 'string' });
453
+
454
+ return {
455
+ pending: false,
456
+ date: {
457
+ timestamp: cancelledDate,
458
+ timestampUNIX: powertools.timestamp(cancelledDate, { output: 'unix' }),
459
+ },
460
+ };
461
+ }
462
+
463
+ // No cancellation
464
+ return {
465
+ pending: false,
466
+ date: {
467
+ timestamp: EPOCH_ZERO,
468
+ timestampUNIX: EPOCH_ZERO_UNIX,
469
+ },
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Resolve trial state from Chargebee subscription
475
+ * Uses trial_start/trial_end unix timestamps (same pattern as Stripe)
476
+ */
477
+ function resolveTrial(raw) {
478
+ const trialStart = raw.trial_start ? raw.trial_start * 1000 : 0;
479
+ const trialEnd = raw.trial_end ? raw.trial_end * 1000 : 0;
480
+ const activated = !!(trialStart && trialEnd);
481
+
482
+ let trialExpires = { timestamp: EPOCH_ZERO, timestampUNIX: EPOCH_ZERO_UNIX };
483
+
484
+ if (trialEnd) {
485
+ const trialEndDate = powertools.timestamp(new Date(trialEnd), { output: 'string' });
486
+ trialExpires = {
487
+ timestamp: trialEndDate,
488
+ timestampUNIX: powertools.timestamp(trialEndDate, { output: 'unix' }),
489
+ };
490
+ }
491
+
492
+ return {
493
+ claimed: activated,
494
+ expires: trialExpires,
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Resolve billing frequency from Chargebee subscription
500
+ *
501
+ * Items model: parse suffix from deterministic item_price_id
502
+ * e.g., "somiibo-pro-monthly" → split('-').pop() → "monthly"
503
+ *
504
+ * Legacy Plans model: use billing_period_unit
505
+ * e.g., "month" → "monthly"
506
+ */
507
+ function resolveFrequency(raw) {
508
+ // Items model: parse from deterministic item_price_id
509
+ const itemPriceId = raw.subscription_items?.[0]?.item_price_id;
510
+
511
+ if (itemPriceId) {
512
+ const suffix = itemPriceId.split('-').pop();
513
+
514
+ if (VALID_FREQUENCIES.includes(suffix)) {
515
+ return suffix;
516
+ }
517
+ }
518
+
519
+ // Legacy Plans model: use billing_period_unit
520
+ const unit = raw.billing_period_unit;
521
+
522
+ return UNIT_TO_FREQUENCY[unit] || null;
523
+ }
524
+
525
+ /**
526
+ * Resolve product by matching Chargebee subscription against config products
527
+ *
528
+ * Items model: subscription_items[0].item_price_id starts with product.chargebee.itemId + '-'
529
+ * e.g., "somiibo-pro-monthly".startsWith("somiibo-pro-") → match
530
+ *
531
+ * Legacy Plans model: plan_id matches product.chargebee.legacyPlanIds[]
532
+ * e.g., "somiibo-premium-monthly-1" in legacyPlanIds → match
533
+ */
534
+ function resolveProduct(raw, config) {
535
+ const itemPriceId = raw.subscription_items?.[0]?.item_price_id;
536
+ const planId = raw.plan_id;
537
+
538
+ if (!config.payment?.products) {
539
+ return { id: 'basic', name: 'Basic' };
540
+ }
541
+
542
+ // Items model takes priority — check all products first
543
+ if (itemPriceId) {
544
+ for (const product of config.payment.products) {
545
+ if (product.chargebee?.itemId && itemPriceId.startsWith(product.chargebee.itemId + '-')) {
546
+ return { id: product.id, name: product.name || product.id };
547
+ }
548
+ }
549
+ }
550
+
551
+ // Legacy Plans model — fallback
552
+ if (planId) {
553
+ for (const product of config.payment.products) {
554
+ if (product.chargebee?.legacyPlanIds?.includes(planId)) {
555
+ return { id: product.id, name: product.name || product.id };
556
+ }
557
+ }
558
+ }
559
+
560
+ return { id: 'basic', name: 'Basic' };
561
+ }
562
+
563
+ /**
564
+ * Resolve product for one-time payments by productId from metadata
565
+ */
566
+ function resolveProductOneTime(productId, config) {
567
+ if (!productId || !config.payment?.products) {
568
+ return { id: productId || 'unknown', name: 'Unknown' };
569
+ }
570
+
571
+ const product = config.payment.products.find(p => p.id === productId);
572
+
573
+ if (!product) {
574
+ return { id: productId, name: productId };
575
+ }
576
+
577
+ return { id: product.id, name: product.name || product.id };
578
+ }
579
+
580
+ /**
581
+ * Resolve subscription expiration from Chargebee data
582
+ * Uses current_term_end (unix timestamp)
583
+ */
584
+ function resolveExpires(raw) {
585
+ const termEnd = raw.current_term_end;
586
+
587
+ if (!termEnd) {
588
+ return {
589
+ timestamp: EPOCH_ZERO,
590
+ timestampUNIX: EPOCH_ZERO_UNIX,
591
+ };
592
+ }
593
+
594
+ const expiresDate = powertools.timestamp(new Date(termEnd * 1000), { output: 'string' });
595
+
596
+ return {
597
+ timestamp: expiresDate,
598
+ timestampUNIX: powertools.timestamp(expiresDate, { output: 'unix' }),
599
+ };
600
+ }
601
+
602
+ /**
603
+ * Resolve subscription start date from Chargebee data
604
+ * Uses started_at or created_at (unix timestamps)
605
+ */
606
+ function resolveStartDate(raw) {
607
+ const startTs = raw.started_at || raw.created_at;
608
+
609
+ if (!startTs) {
610
+ return {
611
+ timestamp: EPOCH_ZERO,
612
+ timestampUNIX: EPOCH_ZERO_UNIX,
613
+ };
614
+ }
615
+
616
+ const startDate = powertools.timestamp(new Date(startTs * 1000), { output: 'string' });
617
+
618
+ return {
619
+ timestamp: startDate,
620
+ timestampUNIX: powertools.timestamp(startDate, { output: 'unix' }),
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Resolve the display price for a product/frequency from config
626
+ */
627
+ function resolvePrice(productId, frequency, config) {
628
+ const product = config.payment?.products?.find(p => p.id === productId);
629
+
630
+ if (!product || !product.prices) {
631
+ return 0;
632
+ }
633
+
634
+ return product.prices[frequency] || 0;
635
+ }
636
+
637
+ module.exports = Chargebee;