@umituz/web-polar-payment 1.0.21 → 1.0.22

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/dist/index.js CHANGED
@@ -118,14 +118,24 @@ function PolarProvider({ adapter, userId, children }) {
118
118
  throw new Error("[polar-billing] Invalid productId: must be a non-empty string");
119
119
  }
120
120
  const uid = userIdRef.current;
121
- const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? void 0 });
122
- if (!isValidCheckoutUrl(result.url)) {
123
- throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://");
121
+ if (!uid) {
122
+ throw new Error("[polar-billing] Cannot start checkout: No authenticated user");
124
123
  }
125
- if (isProductionInsecureUrl(result.url)) {
126
- throw new Error("[polar-billing] ERROR: Using insecure http:// URL in production environment");
124
+ try {
125
+ const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
126
+ if (!isValidCheckoutUrl(result.url)) {
127
+ throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://");
128
+ }
129
+ if (isProductionInsecureUrl(result.url)) {
130
+ throw new Error("[polar-billing] ERROR: Using insecure http:// URL in production environment");
131
+ }
132
+ window.location.href = result.url;
133
+ } catch (error) {
134
+ if (error instanceof Error) {
135
+ throw new Error(`[polar-billing] Checkout failed: ${error.message}`);
136
+ }
137
+ throw error;
127
138
  }
128
- window.location.href = result.url;
129
139
  }, []);
130
140
  const syncSubscription = (0, import_react2.useCallback)(async () => {
131
141
  const uid = userIdRef.current;
@@ -337,6 +347,14 @@ function isTimestamp(value) {
337
347
  }
338
348
 
339
349
  // src/infrastructure/services/firebase-billing.service.ts
350
+ var FirebaseBillingError = class extends Error {
351
+ constructor(message, code, originalError) {
352
+ super(message);
353
+ this.code = code;
354
+ this.originalError = originalError;
355
+ this.name = "FirebaseBillingError";
356
+ }
357
+ };
340
358
  function createFirebaseAdapter(config) {
341
359
  const functions = config.functions;
342
360
  const firestore = config.firestore;
@@ -357,15 +375,7 @@ function createFirebaseAdapter(config) {
357
375
  cancelAtPeriodEnd: config.db?.cancelAtPeriodEndField ?? "cancelAtPeriodEnd",
358
376
  currentPeriodEnd: config.db?.currentPeriodEndField ?? "currentPeriodEnd"
359
377
  };
360
- let httpsCallableCache = null;
361
378
  let firestoreCache = null;
362
- async function getHttpsCallable() {
363
- if (!httpsCallableCache) {
364
- const mod = await import("firebase/functions");
365
- httpsCallableCache = mod.httpsCallable(functions);
366
- }
367
- return httpsCallableCache;
368
- }
369
379
  async function getFirestore() {
370
380
  if (!firestoreCache) {
371
381
  const mod = await import("firebase/firestore");
@@ -374,62 +384,126 @@ function createFirebaseAdapter(config) {
374
384
  return firestoreCache;
375
385
  }
376
386
  async function callable(name, data) {
377
- const httpsCallable = await getHttpsCallable();
378
- const fn = httpsCallable(name);
379
- const result = await fn(data);
380
- return result.data;
387
+ try {
388
+ const { httpsCallable: hc } = await import("firebase/functions");
389
+ const fn = hc(functions, name);
390
+ const result = await fn(data);
391
+ return result.data;
392
+ } catch (error) {
393
+ if (error && typeof error === "object") {
394
+ const err = error;
395
+ throw new FirebaseBillingError(
396
+ err.message || `Function ${name} failed`,
397
+ err.code,
398
+ error
399
+ );
400
+ }
401
+ throw new FirebaseBillingError(
402
+ `Unknown error calling ${name}`,
403
+ "unknown",
404
+ error
405
+ );
406
+ }
407
+ }
408
+ function validateUserId(userId) {
409
+ if (!userId || typeof userId !== "string" || userId.trim() === "") {
410
+ throw new FirebaseBillingError("User ID is required and cannot be empty", "invalid-argument");
411
+ }
381
412
  }
382
413
  return {
383
414
  async getStatus(userId) {
384
- const { doc, getDoc } = await getFirestore();
385
- const docRef = doc(firestore, db.collection, userId);
386
- const snap = await getDoc(docRef);
387
- const exists = snap.exists;
388
- if (!exists) {
389
- return { plan: "free", subscriptionStatus: "none" };
390
- }
391
- const d = snap.data();
392
- let currentPeriodEnd;
393
- const rawEnd = d[db.currentPeriodEnd];
394
- if (rawEnd != null) {
395
- if (isTimestamp(rawEnd)) {
396
- currentPeriodEnd = rawEnd.toDate().toISOString();
397
- } else if (typeof rawEnd === "string") {
398
- currentPeriodEnd = rawEnd;
415
+ try {
416
+ const { doc, getDoc } = await getFirestore();
417
+ const docRef = doc(firestore, db.collection, userId);
418
+ const snap = await getDoc(docRef);
419
+ const exists = snap.exists;
420
+ if (!exists) {
421
+ return { plan: "free", subscriptionStatus: "none" };
399
422
  }
423
+ const d = snap.data();
424
+ let currentPeriodEnd;
425
+ const rawEnd = d[db.currentPeriodEnd];
426
+ if (rawEnd != null) {
427
+ if (isTimestamp(rawEnd)) {
428
+ currentPeriodEnd = rawEnd.toDate().toISOString();
429
+ } else if (typeof rawEnd === "string") {
430
+ currentPeriodEnd = rawEnd;
431
+ }
432
+ }
433
+ return {
434
+ plan: asString(d[db.plan]) ?? "free",
435
+ billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? "monthly"),
436
+ subscriptionId: asString(d[db.subscriptionId]),
437
+ subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? "none"),
438
+ polarCustomerId: asString(d[db.polarCustomerId]),
439
+ cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
440
+ currentPeriodEnd
441
+ };
442
+ } catch (error) {
443
+ if (error instanceof FirebaseBillingError) throw error;
444
+ throw new FirebaseBillingError("Failed to load subscription status", "status-load-failed", error);
400
445
  }
401
- return {
402
- plan: asString(d[db.plan]) ?? "free",
403
- billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? "monthly"),
404
- subscriptionId: asString(d[db.subscriptionId]),
405
- subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? "none"),
406
- polarCustomerId: asString(d[db.polarCustomerId]),
407
- cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
408
- currentPeriodEnd
409
- };
410
446
  },
411
447
  async createCheckout(params) {
412
- return callable(callables.createCheckout, params);
448
+ if (!params.productId || typeof params.productId !== "string" || params.productId.trim() === "") {
449
+ throw new FirebaseBillingError("Product ID is required", "invalid-argument");
450
+ }
451
+ try {
452
+ return await callable(callables.createCheckout, params);
453
+ } catch (error) {
454
+ if (error instanceof FirebaseBillingError) throw error;
455
+ throw new FirebaseBillingError("Failed to create checkout session", "checkout-failed", error);
456
+ }
413
457
  },
414
458
  async syncSubscription(userId, checkoutId) {
415
- return callable(callables.sync, { userId, checkoutId });
459
+ validateUserId(userId);
460
+ try {
461
+ return await callable(
462
+ callables.sync,
463
+ { userId, checkoutId }
464
+ );
465
+ } catch (error) {
466
+ if (error instanceof FirebaseBillingError) throw error;
467
+ throw new FirebaseBillingError("Failed to sync subscription", "sync-failed", error);
468
+ }
416
469
  },
417
470
  async getBillingHistory(userId) {
418
- const result = await callable(
419
- callables.billing,
420
- { userId }
421
- );
422
- return result.orders ?? [];
471
+ validateUserId(userId);
472
+ try {
473
+ const result = await callable(
474
+ callables.billing,
475
+ { userId }
476
+ );
477
+ return result.orders ?? [];
478
+ } catch (error) {
479
+ if (error instanceof FirebaseBillingError) throw error;
480
+ console.error("[FirebaseBilling] Failed to load billing history:", error);
481
+ return [];
482
+ }
423
483
  },
424
484
  async cancelSubscription(reason) {
425
- return callable(callables.cancel, { reason });
485
+ try {
486
+ return await callable(callables.cancel, { reason });
487
+ } catch (error) {
488
+ if (error instanceof FirebaseBillingError) throw error;
489
+ throw new FirebaseBillingError("Failed to cancel subscription", "cancel-failed", error);
490
+ }
426
491
  },
427
492
  async getPortalUrl(userId) {
428
- const result = await callable(
429
- callables.portal,
430
- { userId }
431
- );
432
- return result.url;
493
+ validateUserId(userId);
494
+ try {
495
+ const result = await callable(
496
+ callables.portal,
497
+ { userId }
498
+ );
499
+ if (!result?.url || typeof result.url !== "string") {
500
+ throw new FirebaseBillingError("Invalid portal URL returned", "invalid-response");
501
+ }
502
+ return result.url;
503
+ } catch (error) {
504
+ if (error instanceof FirebaseBillingError) throw error;
505
+ throw new FirebaseBillingError("Failed to get customer portal URL", "portal-failed", error);
506
+ }
433
507
  }
434
508
  };
435
509
  }
package/dist/index.mjs CHANGED
@@ -81,14 +81,24 @@ function PolarProvider({ adapter, userId, children }) {
81
81
  throw new Error("[polar-billing] Invalid productId: must be a non-empty string");
82
82
  }
83
83
  const uid = userIdRef.current;
84
- const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? void 0 });
85
- if (!isValidCheckoutUrl(result.url)) {
86
- throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://");
84
+ if (!uid) {
85
+ throw new Error("[polar-billing] Cannot start checkout: No authenticated user");
87
86
  }
88
- if (isProductionInsecureUrl(result.url)) {
89
- throw new Error("[polar-billing] ERROR: Using insecure http:// URL in production environment");
87
+ try {
88
+ const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
89
+ if (!isValidCheckoutUrl(result.url)) {
90
+ throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://");
91
+ }
92
+ if (isProductionInsecureUrl(result.url)) {
93
+ throw new Error("[polar-billing] ERROR: Using insecure http:// URL in production environment");
94
+ }
95
+ window.location.href = result.url;
96
+ } catch (error) {
97
+ if (error instanceof Error) {
98
+ throw new Error(`[polar-billing] Checkout failed: ${error.message}`);
99
+ }
100
+ throw error;
90
101
  }
91
- window.location.href = result.url;
92
102
  }, []);
93
103
  const syncSubscription = useCallback(async () => {
94
104
  const uid = userIdRef.current;
@@ -300,6 +310,14 @@ function isTimestamp(value) {
300
310
  }
301
311
 
302
312
  // src/infrastructure/services/firebase-billing.service.ts
313
+ var FirebaseBillingError = class extends Error {
314
+ constructor(message, code, originalError) {
315
+ super(message);
316
+ this.code = code;
317
+ this.originalError = originalError;
318
+ this.name = "FirebaseBillingError";
319
+ }
320
+ };
303
321
  function createFirebaseAdapter(config) {
304
322
  const functions = config.functions;
305
323
  const firestore = config.firestore;
@@ -320,15 +338,7 @@ function createFirebaseAdapter(config) {
320
338
  cancelAtPeriodEnd: config.db?.cancelAtPeriodEndField ?? "cancelAtPeriodEnd",
321
339
  currentPeriodEnd: config.db?.currentPeriodEndField ?? "currentPeriodEnd"
322
340
  };
323
- let httpsCallableCache = null;
324
341
  let firestoreCache = null;
325
- async function getHttpsCallable() {
326
- if (!httpsCallableCache) {
327
- const mod = await import("firebase/functions");
328
- httpsCallableCache = mod.httpsCallable(functions);
329
- }
330
- return httpsCallableCache;
331
- }
332
342
  async function getFirestore() {
333
343
  if (!firestoreCache) {
334
344
  const mod = await import("firebase/firestore");
@@ -337,62 +347,126 @@ function createFirebaseAdapter(config) {
337
347
  return firestoreCache;
338
348
  }
339
349
  async function callable(name, data) {
340
- const httpsCallable = await getHttpsCallable();
341
- const fn = httpsCallable(name);
342
- const result = await fn(data);
343
- return result.data;
350
+ try {
351
+ const { httpsCallable: hc } = await import("firebase/functions");
352
+ const fn = hc(functions, name);
353
+ const result = await fn(data);
354
+ return result.data;
355
+ } catch (error) {
356
+ if (error && typeof error === "object") {
357
+ const err = error;
358
+ throw new FirebaseBillingError(
359
+ err.message || `Function ${name} failed`,
360
+ err.code,
361
+ error
362
+ );
363
+ }
364
+ throw new FirebaseBillingError(
365
+ `Unknown error calling ${name}`,
366
+ "unknown",
367
+ error
368
+ );
369
+ }
370
+ }
371
+ function validateUserId(userId) {
372
+ if (!userId || typeof userId !== "string" || userId.trim() === "") {
373
+ throw new FirebaseBillingError("User ID is required and cannot be empty", "invalid-argument");
374
+ }
344
375
  }
345
376
  return {
346
377
  async getStatus(userId) {
347
- const { doc, getDoc } = await getFirestore();
348
- const docRef = doc(firestore, db.collection, userId);
349
- const snap = await getDoc(docRef);
350
- const exists = snap.exists;
351
- if (!exists) {
352
- return { plan: "free", subscriptionStatus: "none" };
353
- }
354
- const d = snap.data();
355
- let currentPeriodEnd;
356
- const rawEnd = d[db.currentPeriodEnd];
357
- if (rawEnd != null) {
358
- if (isTimestamp(rawEnd)) {
359
- currentPeriodEnd = rawEnd.toDate().toISOString();
360
- } else if (typeof rawEnd === "string") {
361
- currentPeriodEnd = rawEnd;
378
+ try {
379
+ const { doc, getDoc } = await getFirestore();
380
+ const docRef = doc(firestore, db.collection, userId);
381
+ const snap = await getDoc(docRef);
382
+ const exists = snap.exists;
383
+ if (!exists) {
384
+ return { plan: "free", subscriptionStatus: "none" };
362
385
  }
386
+ const d = snap.data();
387
+ let currentPeriodEnd;
388
+ const rawEnd = d[db.currentPeriodEnd];
389
+ if (rawEnd != null) {
390
+ if (isTimestamp(rawEnd)) {
391
+ currentPeriodEnd = rawEnd.toDate().toISOString();
392
+ } else if (typeof rawEnd === "string") {
393
+ currentPeriodEnd = rawEnd;
394
+ }
395
+ }
396
+ return {
397
+ plan: asString(d[db.plan]) ?? "free",
398
+ billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? "monthly"),
399
+ subscriptionId: asString(d[db.subscriptionId]),
400
+ subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? "none"),
401
+ polarCustomerId: asString(d[db.polarCustomerId]),
402
+ cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
403
+ currentPeriodEnd
404
+ };
405
+ } catch (error) {
406
+ if (error instanceof FirebaseBillingError) throw error;
407
+ throw new FirebaseBillingError("Failed to load subscription status", "status-load-failed", error);
363
408
  }
364
- return {
365
- plan: asString(d[db.plan]) ?? "free",
366
- billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? "monthly"),
367
- subscriptionId: asString(d[db.subscriptionId]),
368
- subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? "none"),
369
- polarCustomerId: asString(d[db.polarCustomerId]),
370
- cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
371
- currentPeriodEnd
372
- };
373
409
  },
374
410
  async createCheckout(params) {
375
- return callable(callables.createCheckout, params);
411
+ if (!params.productId || typeof params.productId !== "string" || params.productId.trim() === "") {
412
+ throw new FirebaseBillingError("Product ID is required", "invalid-argument");
413
+ }
414
+ try {
415
+ return await callable(callables.createCheckout, params);
416
+ } catch (error) {
417
+ if (error instanceof FirebaseBillingError) throw error;
418
+ throw new FirebaseBillingError("Failed to create checkout session", "checkout-failed", error);
419
+ }
376
420
  },
377
421
  async syncSubscription(userId, checkoutId) {
378
- return callable(callables.sync, { userId, checkoutId });
422
+ validateUserId(userId);
423
+ try {
424
+ return await callable(
425
+ callables.sync,
426
+ { userId, checkoutId }
427
+ );
428
+ } catch (error) {
429
+ if (error instanceof FirebaseBillingError) throw error;
430
+ throw new FirebaseBillingError("Failed to sync subscription", "sync-failed", error);
431
+ }
379
432
  },
380
433
  async getBillingHistory(userId) {
381
- const result = await callable(
382
- callables.billing,
383
- { userId }
384
- );
385
- return result.orders ?? [];
434
+ validateUserId(userId);
435
+ try {
436
+ const result = await callable(
437
+ callables.billing,
438
+ { userId }
439
+ );
440
+ return result.orders ?? [];
441
+ } catch (error) {
442
+ if (error instanceof FirebaseBillingError) throw error;
443
+ console.error("[FirebaseBilling] Failed to load billing history:", error);
444
+ return [];
445
+ }
386
446
  },
387
447
  async cancelSubscription(reason) {
388
- return callable(callables.cancel, { reason });
448
+ try {
449
+ return await callable(callables.cancel, { reason });
450
+ } catch (error) {
451
+ if (error instanceof FirebaseBillingError) throw error;
452
+ throw new FirebaseBillingError("Failed to cancel subscription", "cancel-failed", error);
453
+ }
389
454
  },
390
455
  async getPortalUrl(userId) {
391
- const result = await callable(
392
- callables.portal,
393
- { userId }
394
- );
395
- return result.url;
456
+ validateUserId(userId);
457
+ try {
458
+ const result = await callable(
459
+ callables.portal,
460
+ { userId }
461
+ );
462
+ if (!result?.url || typeof result.url !== "string") {
463
+ throw new FirebaseBillingError("Invalid portal URL returned", "invalid-response");
464
+ }
465
+ return result.url;
466
+ } catch (error) {
467
+ if (error instanceof FirebaseBillingError) throw error;
468
+ throw new FirebaseBillingError("Failed to get customer portal URL", "portal-failed", error);
469
+ }
396
470
  }
397
471
  };
398
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-polar-payment",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "Universal Polar.sh subscription billing — Firebase adapter",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -13,8 +13,6 @@ import { asString, asBoolean, isTimestamp } from '../utils/firebase-helpers.util
13
13
 
14
14
  type FirestoreDoc = (firestore: unknown, collectionPath: string, docId: string) => unknown;
15
15
  type FirestoreGetDoc = (ref: unknown) => Promise<{ exists: boolean; data(): Record<string, unknown> }>;
16
- type HttpsCallableFn = <T = unknown, R = unknown>(data?: T) => Promise<{ data: R }>;
17
- type HttpsCallable = (name: string) => HttpsCallableFn;
18
16
 
19
17
  export interface FirebaseAdapterConfig {
20
18
  functions: unknown;
@@ -38,6 +36,20 @@ export interface FirebaseAdapterConfig {
38
36
  };
39
37
  }
40
38
 
39
+ /**
40
+ * Custom error class for Firebase billing operations
41
+ */
42
+ export class FirebaseBillingError extends Error {
43
+ constructor(
44
+ message: string,
45
+ public code?: string,
46
+ public originalError?: unknown
47
+ ) {
48
+ super(message);
49
+ this.name = 'FirebaseBillingError';
50
+ }
51
+ }
52
+
41
53
  export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapter {
42
54
  const functions = config.functions;
43
55
  const firestore = config.firestore;
@@ -61,17 +73,8 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
61
73
  currentPeriodEnd: config.db?.currentPeriodEndField ?? 'currentPeriodEnd',
62
74
  };
63
75
 
64
- let httpsCallableCache: HttpsCallable | null = null;
65
76
  let firestoreCache: { doc: FirestoreDoc; getDoc: FirestoreGetDoc } | null = null;
66
77
 
67
- async function getHttpsCallable(): Promise<HttpsCallable> {
68
- if (!httpsCallableCache) {
69
- const mod = await import('firebase/functions');
70
- httpsCallableCache = mod.httpsCallable(functions) as HttpsCallable;
71
- }
72
- return httpsCallableCache;
73
- }
74
-
75
78
  async function getFirestore(): Promise<{ doc: FirestoreDoc; getDoc: FirestoreGetDoc }> {
76
79
  if (!firestoreCache) {
77
80
  const mod = await import('firebase/firestore');
@@ -80,73 +83,153 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
80
83
  return firestoreCache;
81
84
  }
82
85
 
86
+ /**
87
+ * Call a Firebase callable function with proper error handling
88
+ */
83
89
  async function callable<T = unknown, R = unknown>(name: string, data?: T): Promise<R> {
84
- const httpsCallable = await getHttpsCallable();
85
- const fn = httpsCallable(name) as (data?: T) => Promise<{ data: R }>;
86
- const result = await fn(data);
87
- return result.data;
90
+ try {
91
+ const { httpsCallable: hc } = await import('firebase/functions');
92
+ const fn = hc(functions, name) as (data?: T) => Promise<{ data: R }>;
93
+ const result = await fn(data);
94
+ return result.data;
95
+ } catch (error) {
96
+ // Handle Firebase Functions errors
97
+ if (error && typeof error === 'object') {
98
+ const err = error as { code?: string; message?: string };
99
+ throw new FirebaseBillingError(
100
+ err.message || `Function ${name} failed`,
101
+ err.code,
102
+ error
103
+ );
104
+ }
105
+ throw new FirebaseBillingError(
106
+ `Unknown error calling ${name}`,
107
+ 'unknown',
108
+ error
109
+ );
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Validate userId is not empty
115
+ */
116
+ function validateUserId(userId: string | undefined): void {
117
+ if (!userId || typeof userId !== 'string' || userId.trim() === '') {
118
+ throw new FirebaseBillingError('User ID is required and cannot be empty', 'invalid-argument');
119
+ }
88
120
  }
89
121
 
90
122
  return {
91
123
  async getStatus(userId: string): Promise<SubscriptionStatus> {
92
- const { doc, getDoc } = await getFirestore();
93
- const docRef = doc(firestore, db.collection, userId);
94
- const snap = await getDoc(docRef);
95
-
96
- const exists = (snap as { exists: boolean }).exists;
97
- if (!exists) {
98
- return { plan: 'free', subscriptionStatus: 'none' };
99
- }
124
+ try {
125
+ const { doc, getDoc } = await getFirestore();
126
+ const docRef = doc(firestore, db.collection, userId);
127
+ const snap = await getDoc(docRef);
128
+
129
+ const exists = (snap as { exists: boolean }).exists;
130
+ if (!exists) {
131
+ return { plan: 'free', subscriptionStatus: 'none' };
132
+ }
100
133
 
101
- const d = snap.data();
134
+ const d = snap.data();
102
135
 
103
- let currentPeriodEnd: string | undefined;
104
- const rawEnd = d[db.currentPeriodEnd];
105
- if (rawEnd != null) {
106
- if (isTimestamp(rawEnd)) {
107
- currentPeriodEnd = rawEnd.toDate().toISOString();
108
- } else if (typeof rawEnd === 'string') {
109
- currentPeriodEnd = rawEnd;
136
+ let currentPeriodEnd: string | undefined;
137
+ const rawEnd = d[db.currentPeriodEnd];
138
+ if (rawEnd != null) {
139
+ if (isTimestamp(rawEnd)) {
140
+ currentPeriodEnd = rawEnd.toDate().toISOString();
141
+ } else if (typeof rawEnd === 'string') {
142
+ currentPeriodEnd = rawEnd;
143
+ }
110
144
  }
111
- }
112
145
 
113
- return {
114
- plan: asString(d[db.plan]) ?? 'free',
115
- billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? 'monthly'),
116
- subscriptionId: asString(d[db.subscriptionId]),
117
- subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? 'none'),
118
- polarCustomerId: asString(d[db.polarCustomerId]),
119
- cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
120
- currentPeriodEnd,
121
- };
146
+ return {
147
+ plan: asString(d[db.plan]) ?? 'free',
148
+ billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? 'monthly'),
149
+ subscriptionId: asString(d[db.subscriptionId]),
150
+ subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? 'none'),
151
+ polarCustomerId: asString(d[db.polarCustomerId]),
152
+ cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
153
+ currentPeriodEnd,
154
+ };
155
+ } catch (error) {
156
+ if (error instanceof FirebaseBillingError) throw error;
157
+ throw new FirebaseBillingError('Failed to load subscription status', 'status-load-failed', error);
158
+ }
122
159
  },
123
160
 
124
161
  async createCheckout(params: CheckoutParams): Promise<CheckoutResult> {
125
- return callable<CheckoutParams, CheckoutResult>(callables.createCheckout, params);
162
+ // Validate checkout params
163
+ if (!params.productId || typeof params.productId !== 'string' || params.productId.trim() === '') {
164
+ throw new FirebaseBillingError('Product ID is required', 'invalid-argument');
165
+ }
166
+
167
+ try {
168
+ return await callable<CheckoutParams, CheckoutResult>(callables.createCheckout, params);
169
+ } catch (error) {
170
+ if (error instanceof FirebaseBillingError) throw error;
171
+ throw new FirebaseBillingError('Failed to create checkout session', 'checkout-failed', error);
172
+ }
126
173
  },
127
174
 
128
175
  async syncSubscription(userId: string, checkoutId?: string): Promise<SyncResult> {
129
- return callable<{ userId: string; checkoutId?: string }, SyncResult>(callables.sync, { userId, checkoutId });
176
+ validateUserId(userId);
177
+
178
+ try {
179
+ return await callable<{ userId: string; checkoutId?: string }, SyncResult>(
180
+ callables.sync,
181
+ { userId, checkoutId }
182
+ );
183
+ } catch (error) {
184
+ if (error instanceof FirebaseBillingError) throw error;
185
+ throw new FirebaseBillingError('Failed to sync subscription', 'sync-failed', error);
186
+ }
130
187
  },
131
188
 
132
189
  async getBillingHistory(userId: string): Promise<OrderItem[]> {
133
- const result = await callable<{ userId: string }, { orders?: OrderItem[] }>(
134
- callables.billing,
135
- { userId },
136
- );
137
- return result.orders ?? [];
190
+ validateUserId(userId);
191
+
192
+ try {
193
+ const result = await callable<{ userId: string }, { orders?: OrderItem[] }>(
194
+ callables.billing,
195
+ { userId }
196
+ );
197
+ return result.orders ?? [];
198
+ } catch (error) {
199
+ if (error instanceof FirebaseBillingError) throw error;
200
+ // Return empty array on error instead of throwing
201
+ console.error('[FirebaseBilling] Failed to load billing history:', error);
202
+ return [];
203
+ }
138
204
  },
139
205
 
140
206
  async cancelSubscription(reason?: CancellationReason): Promise<CancelResult> {
141
- return callable<{ reason?: string }, CancelResult>(callables.cancel, { reason });
207
+ try {
208
+ return await callable<{ reason?: string }, CancelResult>(callables.cancel, { reason });
209
+ } catch (error) {
210
+ if (error instanceof FirebaseBillingError) throw error;
211
+ throw new FirebaseBillingError('Failed to cancel subscription', 'cancel-failed', error);
212
+ }
142
213
  },
143
214
 
144
215
  async getPortalUrl(userId: string): Promise<string> {
145
- const result = await callable<{ userId: string }, { url: string }>(
146
- callables.portal,
147
- { userId },
148
- );
149
- return result.url;
216
+ validateUserId(userId);
217
+
218
+ try {
219
+ const result = await callable<{ userId: string }, { url: string }>(
220
+ callables.portal,
221
+ { userId }
222
+ );
223
+
224
+ if (!result?.url || typeof result.url !== 'string') {
225
+ throw new FirebaseBillingError('Invalid portal URL returned', 'invalid-response');
226
+ }
227
+
228
+ return result.url;
229
+ } catch (error) {
230
+ if (error instanceof FirebaseBillingError) throw error;
231
+ throw new FirebaseBillingError('Failed to get customer portal URL', 'portal-failed', error);
232
+ }
150
233
  },
151
234
  };
152
235
  }
@@ -79,17 +79,30 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
79
79
  }
80
80
 
81
81
  const uid = userIdRef.current;
82
- const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? undefined });
83
-
84
- if (!isValidCheckoutUrl(result.url)) {
85
- throw new Error('[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://');
82
+ if (!uid) {
83
+ throw new Error('[polar-billing] Cannot start checkout: No authenticated user');
86
84
  }
87
85
 
88
- if (isProductionInsecureUrl(result.url)) {
89
- throw new Error('[polar-billing] ERROR: Using insecure http:// URL in production environment');
90
- }
86
+ try {
87
+ const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
88
+
89
+ if (!isValidCheckoutUrl(result.url)) {
90
+ throw new Error('[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://');
91
+ }
92
+
93
+ if (isProductionInsecureUrl(result.url)) {
94
+ throw new Error('[polar-billing] ERROR: Using insecure http:// URL in production environment');
95
+ }
91
96
 
92
- window.location.href = result.url;
97
+ // Navigate to checkout URL
98
+ window.location.href = result.url;
99
+ } catch (error) {
100
+ // Re-throw with additional context
101
+ if (error instanceof Error) {
102
+ throw new Error(`[polar-billing] Checkout failed: ${error.message}`);
103
+ }
104
+ throw error;
105
+ }
93
106
  }, []);
94
107
 
95
108
  const syncSubscription = useCallback(async (): Promise<SyncResult> => {