@umituz/web-polar-payment 1.0.14 → 1.0.16

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.
@@ -9,44 +9,15 @@ import type {
9
9
  SyncResult,
10
10
  } from '../../domain/entities';
11
11
  import { normalizeStatus, normalizeBillingCycle } from '../utils/normalization.util';
12
+ import { asString, asBoolean, isTimestamp } from '../utils/firebase-helpers.util';
12
13
 
13
- /**
14
- * Type guard to safely extract string value from unknown Firestore data
15
- */
16
- function asString(value: unknown): string | undefined {
17
- if (typeof value === 'string') return value;
18
- return undefined;
19
- }
20
-
21
- /**
22
- * Type guard to safely extract boolean value from unknown Firestore data
23
- */
24
- function asBoolean(value: unknown): boolean | undefined {
25
- if (typeof value === 'boolean') return value;
26
- return undefined;
27
- }
28
-
29
- /**
30
- * Type guard to safely check if value is an object with toDate method
31
- */
32
- function isTimestamp(value: unknown): value is { toDate(): Date } {
33
- return (
34
- typeof value === 'object' &&
35
- value !== null &&
36
- 'toDate' in value &&
37
- typeof (value as { toDate: unknown }).toDate === 'function'
38
- );
39
- }
40
-
41
- // Internal type aliases for Firebase SDK compatibility
42
- // Using 'any' internally to avoid DTS build issues with external Firebase packages
43
- type FirebaseFunctions = any;
44
- type FirebaseFirestore = any;
14
+ type FirestoreDoc = (firestore: unknown, collectionPath: string, docId: string) => unknown;
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;
45
18
 
46
19
  export interface FirebaseAdapterConfig {
47
- /** Firebase Functions instance from firebase/functions */
48
20
  functions: unknown;
49
- /** Firebase Firestore instance from firebase/firestore */
50
21
  firestore: unknown;
51
22
  callables?: {
52
23
  createCheckout?: string;
@@ -67,14 +38,9 @@ export interface FirebaseAdapterConfig {
67
38
  };
68
39
  }
69
40
 
70
- /**
71
- * Firebase Billing Service
72
- * @description Implementation of PolarAdapter for Firebase Functions and Firestore.
73
- */
74
41
  export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapter {
75
- // Cast internally to Firebase types for implementation
76
- const functions = config.functions as FirebaseFunctions;
77
- const firestore = config.firestore as FirebaseFirestore;
42
+ const functions = config.functions;
43
+ const firestore = config.firestore;
78
44
 
79
45
  const callables = {
80
46
  createCheckout: config.callables?.createCheckout ?? 'createCheckoutSession',
@@ -95,30 +61,28 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
95
61
  currentPeriodEnd: config.db?.currentPeriodEndField ?? 'currentPeriodEnd',
96
62
  };
97
63
 
98
- // Cache imports to avoid repeated dynamic import overhead
99
- // Reduces latency on subsequent calls and GC pressure
100
- let httpsCallableCache: typeof import('firebase/functions')['httpsCallable'] | null = null;
101
- let firestoreCache: { doc: any; getDoc: any } | null = null;
64
+ let httpsCallableCache: HttpsCallable | null = null;
65
+ let firestoreCache: { doc: FirestoreDoc; getDoc: FirestoreGetDoc } | null = null;
102
66
 
103
- async function getHttpsCallable() {
67
+ async function getHttpsCallable(): Promise<HttpsCallable> {
104
68
  if (!httpsCallableCache) {
105
69
  const mod = await import('firebase/functions');
106
- httpsCallableCache = mod.httpsCallable;
70
+ httpsCallableCache = mod.httpsCallable(functions) as HttpsCallable;
107
71
  }
108
72
  return httpsCallableCache;
109
73
  }
110
74
 
111
- async function getFirestore() {
75
+ async function getFirestore(): Promise<{ doc: FirestoreDoc; getDoc: FirestoreGetDoc }> {
112
76
  if (!firestoreCache) {
113
77
  const mod = await import('firebase/firestore');
114
- firestoreCache = { doc: mod.doc, getDoc: mod.getDoc };
78
+ firestoreCache = { doc: mod.doc as FirestoreDoc, getDoc: mod.getDoc as FirestoreGetDoc };
115
79
  }
116
80
  return firestoreCache;
117
81
  }
118
82
 
119
83
  async function callable<T = unknown, R = unknown>(name: string, data?: T): Promise<R> {
120
84
  const httpsCallable = await getHttpsCallable();
121
- const fn = (httpsCallable as any)(functions, name) as (data?: T) => Promise<{ data: R }>;
85
+ const fn = httpsCallable(name) as (data?: T) => Promise<{ data: R }>;
122
86
  const result = await fn(data);
123
87
  return result.data;
124
88
  }
@@ -126,13 +90,15 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
126
90
  return {
127
91
  async getStatus(userId: string): Promise<SubscriptionStatus> {
128
92
  const { doc, getDoc } = await getFirestore();
129
- const snap = await getDoc((doc as any)(firestore, db.collection, userId));
93
+ const docRef = doc(firestore, db.collection, userId);
94
+ const snap = await getDoc(docRef);
130
95
 
131
- if (!snap.exists()) {
96
+ const exists = (snap as { exists: boolean }).exists;
97
+ if (!exists) {
132
98
  return { plan: 'free', subscriptionStatus: 'none' };
133
99
  }
134
100
 
135
- const d = snap.data() as Record<string, unknown>;
101
+ const d = snap.data();
136
102
 
137
103
  let currentPeriodEnd: string | undefined;
138
104
  const rawEnd = d[db.currentPeriodEnd];
@@ -159,14 +125,14 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
159
125
  return callable<CheckoutParams, CheckoutResult>(callables.createCheckout, params);
160
126
  },
161
127
 
162
- async syncSubscription(_userId: string, _checkoutId?: string): Promise<SyncResult> {
163
- return callable<Record<string, never>, SyncResult>(callables.sync, {});
128
+ async syncSubscription(userId: string, checkoutId?: string): Promise<SyncResult> {
129
+ return callable<{ userId: string; checkoutId?: string }, SyncResult>(callables.sync, { userId, checkoutId });
164
130
  },
165
131
 
166
- async getBillingHistory(_userId: string): Promise<OrderItem[]> {
167
- const result = await callable<Record<string, never>, { orders?: OrderItem[] }>(
132
+ async getBillingHistory(userId: string): Promise<OrderItem[]> {
133
+ const result = await callable<{ userId: string }, { orders?: OrderItem[] }>(
168
134
  callables.billing,
169
- {},
135
+ { userId },
170
136
  );
171
137
  return result.orders ?? [];
172
138
  },
@@ -175,14 +141,12 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
175
141
  return callable<{ reason?: string }, CancelResult>(callables.cancel, { reason });
176
142
  },
177
143
 
178
- async getPortalUrl(_userId: string): Promise<string> {
179
- const result = await callable<Record<string, never>, { url?: string; customerPortalUrl?: string }>(
144
+ async getPortalUrl(userId: string): Promise<string> {
145
+ const result = await callable<{ userId: string }, { url: string }>(
180
146
  callables.portal,
181
- {},
147
+ { userId },
182
148
  );
183
- const url = result.url ?? result.customerPortalUrl;
184
- if (!url) throw new Error('No portal URL returned from Cloud Function');
185
- return url;
149
+ return result.url;
186
150
  },
187
151
  };
188
152
  }
@@ -0,0 +1,18 @@
1
+ export function asString(value: unknown): string | undefined {
2
+ if (typeof value === 'string') return value;
3
+ return undefined;
4
+ }
5
+
6
+ export function asBoolean(value: unknown): boolean | undefined {
7
+ if (typeof value === 'boolean') return value;
8
+ return undefined;
9
+ }
10
+
11
+ export function isTimestamp(value: unknown): value is { toDate(): Date } {
12
+ return (
13
+ typeof value === 'object' &&
14
+ value !== null &&
15
+ 'toDate' in value &&
16
+ 'function' === typeof (value as { toDate?: unknown }).toDate
17
+ );
18
+ }
@@ -1,33 +1,23 @@
1
1
  import type { SubscriptionStatusValue, BillingCycle } from '../../domain/entities';
2
+ import { SUBSCRIPTION_STATUS } from '../constants/billing.constants';
2
3
 
3
- // Static map outside function scope - created once, reused forever
4
- // Reduces GC pressure from repeated object allocations
5
- const STATUS_MAP: Readonly<Record<string, SubscriptionStatusValue>> = {
6
- active: 'active',
7
- trialing: 'trialing',
8
- past_due: 'past_due',
9
- incomplete: 'incomplete',
10
- incomplete_expired: 'incomplete_expired',
11
- unpaid: 'unpaid',
12
- canceled: 'canceled',
13
- cancelled: 'canceled',
14
- revoked: 'revoked',
15
- none: 'none',
16
- };
4
+ const STATUS_MAP: Readonly<Record<string, SubscriptionStatusValue>> = Object.freeze({
5
+ [SUBSCRIPTION_STATUS.ACTIVE]: SUBSCRIPTION_STATUS.ACTIVE,
6
+ [SUBSCRIPTION_STATUS.CANCELED]: SUBSCRIPTION_STATUS.CANCELED,
7
+ [SUBSCRIPTION_STATUS.REVOKED]: SUBSCRIPTION_STATUS.REVOKED,
8
+ [SUBSCRIPTION_STATUS.TRIALING]: SUBSCRIPTION_STATUS.TRIALING,
9
+ [SUBSCRIPTION_STATUS.PAST_DUE]: SUBSCRIPTION_STATUS.PAST_DUE,
10
+ [SUBSCRIPTION_STATUS.INCOMPLETE]: SUBSCRIPTION_STATUS.INCOMPLETE,
11
+ [SUBSCRIPTION_STATUS.INCOMPLETE_EXPIRED]: SUBSCRIPTION_STATUS.INCOMPLETE_EXPIRED,
12
+ [SUBSCRIPTION_STATUS.UNPAID]: SUBSCRIPTION_STATUS.UNPAID,
13
+ [SUBSCRIPTION_STATUS.NONE]: SUBSCRIPTION_STATUS.NONE,
14
+ });
17
15
 
18
- /**
19
- * Normalize a raw Polar status string to a known value.
20
- * @description Defaults to 'none' for unknown statuses or non-string input.
21
- */
22
16
  export function normalizeStatus(raw: string): SubscriptionStatusValue {
23
- if (typeof raw !== 'string') return 'none';
17
+ if (raw == null || typeof raw !== 'string') return 'none';
24
18
  return STATUS_MAP[raw.toLowerCase()] ?? 'none';
25
19
  }
26
20
 
27
- /**
28
- * Normalize billing interval
29
- * @description Maps 'month'/'year' to 'monthly'/'yearly'. Defaults to 'monthly' for unknown values or non-string input.
30
- */
31
21
  export function normalizeBillingCycle(interval: string): BillingCycle {
32
22
  if (typeof interval !== 'string') return 'monthly';
33
23
  const normalized = interval.toLowerCase();
@@ -0,0 +1,17 @@
1
+ export function normalizeUserId(userId: string | undefined): string | undefined {
2
+ if (typeof userId !== 'string') return undefined;
3
+ const trimmed = userId.trim();
4
+ return trimmed.length > 0 ? trimmed : undefined;
5
+ }
6
+
7
+ export function isValidProductId(productId: unknown): productId is string {
8
+ return typeof productId === 'string' && productId.trim().length > 0;
9
+ }
10
+
11
+ export function isValidCheckoutUrl(url: string): boolean {
12
+ return url.startsWith('https://') || url.startsWith('http://');
13
+ }
14
+
15
+ export function isProductionInsecureUrl(url: string): boolean {
16
+ return url.startsWith('http://') && !url.includes('localhost') && !url.includes('127.0.0.1');
17
+ }
@@ -16,11 +16,7 @@ import type {
16
16
  OrderItem,
17
17
  } from '../../domain/entities';
18
18
  import { PolarContext } from '../hooks/usePolarBilling';
19
-
20
- /**
21
- * PolarProvider Component
22
- * @description Context provider for Polar billing management.
23
- */
19
+ import { normalizeUserId, isValidProductId, isValidCheckoutUrl, isProductionInsecureUrl } from '../../infrastructure/utils/validations.util';
24
20
 
25
21
  interface PolarProviderProps {
26
22
  adapter: PolarAdapter;
@@ -33,28 +29,19 @@ const FREE_STATUS: SubscriptionStatus = {
33
29
  subscriptionStatus: 'none',
34
30
  };
35
31
 
36
- function normalizeUserId(userId: string | undefined): string | undefined {
37
- if (typeof userId !== 'string') return undefined;
38
- const trimmed = userId.trim();
39
- return trimmed.length > 0 ? trimmed : undefined;
40
- }
41
-
42
32
  export function PolarProvider({ adapter, userId, children }: PolarProviderProps) {
43
33
  const [status, setStatus] = useState<SubscriptionStatus>(FREE_STATUS);
44
34
  const [loading, setLoading] = useState(true);
45
35
 
46
- // Store adapter and setters in refs to create stable callbacks
47
36
  const adapterRef = useRef(adapter);
48
37
  const statusRef = useRef({ setStatus, setLoading });
49
38
  const userIdRef = useRef(normalizeUserId(userId));
50
- const refreshAbortRef = useRef<AbortController | null>(null);
39
+ const refreshMountedRef = useRef(true);
51
40
 
52
- // Keep refs in sync
53
41
  adapterRef.current = adapter;
54
42
  statusRef.current = { setStatus, setLoading };
55
43
  userIdRef.current = normalizeUserId(userId);
56
44
 
57
- // Stable refresh function with no dependencies - prevents cascading re-renders
58
45
  const refresh = useCallback(async () => {
59
46
  const uid = userIdRef.current;
60
47
  const { setStatus, setLoading } = statusRef.current;
@@ -65,37 +52,45 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
65
52
  return;
66
53
  }
67
54
 
68
- refreshAbortRef.current?.abort();
69
- const ctrl = new AbortController();
70
- refreshAbortRef.current = ctrl;
55
+ refreshMountedRef.current = true;
71
56
 
72
57
  try {
73
58
  setLoading(true);
74
59
  const s = await adapterRef.current.getStatus(uid);
75
- if (!ctrl.signal.aborted) setStatus(s);
60
+ if (refreshMountedRef.current) setStatus(s);
76
61
  } catch (err) {
77
- if (!ctrl.signal.aborted) {
78
- console.error('[polar-billing] getStatus failed:', err);
62
+ if (refreshMountedRef.current) {
79
63
  setStatus(FREE_STATUS);
80
64
  }
81
65
  } finally {
82
- if (!ctrl.signal.aborted) setLoading(false);
66
+ if (refreshMountedRef.current) setLoading(false);
83
67
  }
84
- }, []); // No dependencies - completely stable
68
+ }, []);
85
69
 
86
70
  useEffect(() => {
71
+ refreshMountedRef.current = true;
87
72
  refresh();
88
- return () => { refreshAbortRef.current?.abort(); };
89
- }, [refresh]); // Only re-run if refresh identity changes (never)
73
+ return () => { refreshMountedRef.current = false; };
74
+ }, [userId]);
90
75
 
91
76
  const startCheckout = useCallback(async (params: CheckoutParams) => {
77
+ if (!isValidProductId(params.productId)) {
78
+ throw new Error('[polar-billing] Invalid productId: must be a non-empty string');
79
+ }
80
+
92
81
  const uid = userIdRef.current;
93
82
  const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? undefined });
94
- if (!result.url.startsWith('https://')) {
95
- throw new Error('[polar-billing] Invalid checkout URL returned: URL must start with https://');
83
+
84
+ if (!isValidCheckoutUrl(result.url)) {
85
+ throw new Error('[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://');
86
+ }
87
+
88
+ if (isProductionInsecureUrl(result.url)) {
89
+ throw new Error('[polar-billing] ERROR: Using insecure http:// URL in production environment');
96
90
  }
91
+
97
92
  window.location.href = result.url;
98
- }, []); // No dependencies - stable
93
+ }, []);
99
94
 
100
95
  const syncSubscription = useCallback(async (): Promise<SyncResult> => {
101
96
  const uid = userIdRef.current;
@@ -105,13 +100,13 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
105
100
  const result = await adapterRef.current.syncSubscription(uid, checkoutId);
106
101
  if (result.synced) await refresh();
107
102
  return result;
108
- }, [refresh]); // Only depends on stable refresh
103
+ }, [refresh]);
109
104
 
110
105
  const getBillingHistory = useCallback(async (): Promise<OrderItem[]> => {
111
106
  const uid = userIdRef.current;
112
107
  if (!uid) return [];
113
108
  return adapterRef.current.getBillingHistory(uid);
114
- }, []); // No dependencies - stable
109
+ }, []);
115
110
 
116
111
  const cancelSubscription = useCallback(
117
112
  async (reason?: CancellationReason): Promise<CancelResult> => {
@@ -119,17 +114,15 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
119
114
  if (result.success) await refresh();
120
115
  return result;
121
116
  },
122
- [refresh], // Only depends on stable refresh
117
+ [refresh],
123
118
  );
124
119
 
125
120
  const getPortalUrl = useCallback(async (): Promise<string> => {
126
121
  const uid = userIdRef.current;
127
122
  if (!uid) throw new Error('[polar-billing] Cannot get portal URL: No authenticated user');
128
123
  return adapterRef.current.getPortalUrl(uid);
129
- }, []); // No dependencies - stable
124
+ }, []);
130
125
 
131
- // Memoized context value - only recreates when status/loading changes
132
- // All functions are stable, so they don't trigger re-creation
133
126
  const value = useMemo(
134
127
  () => ({
135
128
  status,
@@ -141,7 +134,7 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
141
134
  cancelSubscription,
142
135
  getPortalUrl,
143
136
  }),
144
- [status, loading, refresh, syncSubscription], // startCheckout, getBillingHistory, cancelSubscription, getPortalUrl are stable
137
+ [status, loading, refresh, syncSubscription],
145
138
  );
146
139
 
147
140
  return <PolarContext.Provider value={value}>{children}</PolarContext.Provider>;
@@ -21,14 +21,8 @@ export interface PolarContextValue {
21
21
 
22
22
  export const PolarContext = createContext<PolarContextValue | undefined>(undefined);
23
23
 
24
- /**
25
- * usePolarBilling Hook
26
- * @description Hook to access Polar billing context
27
- */
28
24
  export function usePolarBilling(): PolarContextValue {
29
25
  const ctx = useContext(PolarContext);
30
26
  if (!ctx) throw new Error('usePolarBilling must be used within <PolarProvider>');
31
27
  return ctx;
32
28
  }
33
-
34
- export const useSubscription = usePolarBilling;
@@ -1,7 +1,2 @@
1
- /**
2
- * Presentation Layer
3
- * Subpath: @umituz/web-polar-payment/presentation
4
- */
5
-
6
1
  export * from './components/PolarProvider';
7
2
  export * from './hooks/usePolarBilling';