@umituz/web-polar-payment 1.0.13 → 1.0.15

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.mjs CHANGED
@@ -1,19 +1,32 @@
1
+ // src/infrastructure/constants/billing.constants.ts
2
+ var SUBSCRIPTION_STATUS = Object.freeze({
3
+ ACTIVE: "active",
4
+ CANCELED: "canceled",
5
+ REVOKED: "revoked",
6
+ TRIALING: "trialing",
7
+ PAST_DUE: "past_due",
8
+ INCOMPLETE: "incomplete",
9
+ INCOMPLETE_EXPIRED: "incomplete_expired",
10
+ UNPAID: "unpaid",
11
+ NONE: "none"
12
+ });
13
+ var FREE_PLAN = "free";
14
+
1
15
  // src/infrastructure/utils/normalization.util.ts
16
+ var STATUS_MAP = Object.freeze({
17
+ [SUBSCRIPTION_STATUS.ACTIVE]: SUBSCRIPTION_STATUS.ACTIVE,
18
+ [SUBSCRIPTION_STATUS.CANCELED]: SUBSCRIPTION_STATUS.CANCELED,
19
+ [SUBSCRIPTION_STATUS.REVOKED]: SUBSCRIPTION_STATUS.REVOKED,
20
+ [SUBSCRIPTION_STATUS.TRIALING]: SUBSCRIPTION_STATUS.TRIALING,
21
+ [SUBSCRIPTION_STATUS.PAST_DUE]: SUBSCRIPTION_STATUS.PAST_DUE,
22
+ [SUBSCRIPTION_STATUS.INCOMPLETE]: SUBSCRIPTION_STATUS.INCOMPLETE,
23
+ [SUBSCRIPTION_STATUS.INCOMPLETE_EXPIRED]: SUBSCRIPTION_STATUS.INCOMPLETE_EXPIRED,
24
+ [SUBSCRIPTION_STATUS.UNPAID]: SUBSCRIPTION_STATUS.UNPAID,
25
+ [SUBSCRIPTION_STATUS.NONE]: SUBSCRIPTION_STATUS.NONE
26
+ });
2
27
  function normalizeStatus(raw) {
3
- const map = {
4
- active: "active",
5
- trialing: "trialing",
6
- past_due: "past_due",
7
- incomplete: "incomplete",
8
- incomplete_expired: "incomplete_expired",
9
- unpaid: "unpaid",
10
- canceled: "canceled",
11
- cancelled: "canceled",
12
- revoked: "revoked",
13
- none: "none"
14
- };
15
- if (typeof raw !== "string") return "none";
16
- return map[raw.toLowerCase()] ?? "none";
28
+ if (raw == null || typeof raw !== "string") return "none";
29
+ return STATUS_MAP[raw.toLowerCase()] ?? "none";
17
30
  }
18
31
  function normalizeBillingCycle(interval) {
19
32
  if (typeof interval !== "string") return "monthly";
@@ -23,7 +36,7 @@ function normalizeBillingCycle(interval) {
23
36
  return "monthly";
24
37
  }
25
38
 
26
- // src/infrastructure/services/firebase-billing.service.ts
39
+ // src/infrastructure/utils/firebase-helpers.util.ts
27
40
  function asString(value) {
28
41
  if (typeof value === "string") return value;
29
42
  return void 0;
@@ -35,6 +48,8 @@ function asBoolean(value) {
35
48
  function isTimestamp(value) {
36
49
  return typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function";
37
50
  }
51
+
52
+ // src/infrastructure/services/firebase-billing.service.ts
38
53
  function createFirebaseAdapter(config) {
39
54
  const functions = config.functions;
40
55
  const firestore = config.firestore;
@@ -55,15 +70,31 @@ function createFirebaseAdapter(config) {
55
70
  cancelAtPeriodEnd: config.db?.cancelAtPeriodEndField ?? "cancelAtPeriodEnd",
56
71
  currentPeriodEnd: config.db?.currentPeriodEndField ?? "currentPeriodEnd"
57
72
  };
73
+ let httpsCallableCache = null;
74
+ let firestoreCache = null;
75
+ async function getHttpsCallable() {
76
+ if (!httpsCallableCache) {
77
+ const mod = await import("firebase/functions");
78
+ httpsCallableCache = mod.httpsCallable;
79
+ }
80
+ return httpsCallableCache;
81
+ }
82
+ async function getFirestore() {
83
+ if (!firestoreCache) {
84
+ const mod = await import("firebase/firestore");
85
+ firestoreCache = { doc: mod.doc, getDoc: mod.getDoc };
86
+ }
87
+ return firestoreCache;
88
+ }
58
89
  async function callable(name, data) {
59
- const { httpsCallable } = await import("firebase/functions");
90
+ const httpsCallable = await getHttpsCallable();
60
91
  const fn = httpsCallable(functions, name);
61
92
  const result = await fn(data);
62
93
  return result.data;
63
94
  }
64
95
  return {
65
96
  async getStatus(userId) {
66
- const { doc, getDoc } = await import("firebase/firestore");
97
+ const { doc, getDoc } = await getFirestore();
67
98
  const snap = await getDoc(doc(firestore, db.collection, userId));
68
99
  if (!snap.exists()) {
69
100
  return { plan: "free", subscriptionStatus: "none" };
@@ -109,26 +140,26 @@ function createFirebaseAdapter(config) {
109
140
  callables.portal,
110
141
  {}
111
142
  );
112
- const url = result.url ?? result.customerPortalUrl;
113
- if (!url) throw new Error("No portal URL returned from Cloud Function");
114
- return url;
143
+ return result.url;
115
144
  }
116
145
  };
117
146
  }
118
147
 
119
- // src/infrastructure/constants/billing.constants.ts
120
- var SUBSCRIPTION_STATUS = {
121
- ACTIVE: "active",
122
- CANCELED: "canceled",
123
- REVOKED: "revoked",
124
- TRIALING: "trialing",
125
- PAST_DUE: "past_due",
126
- INCOMPLETE: "incomplete",
127
- INCOMPLETE_EXPIRED: "incomplete_expired",
128
- UNPAID: "unpaid",
129
- NONE: "none"
130
- };
131
- var FREE_PLAN = "free";
148
+ // src/infrastructure/utils/validations.util.ts
149
+ function normalizeUserId(userId) {
150
+ if (typeof userId !== "string") return void 0;
151
+ const trimmed = userId.trim();
152
+ return trimmed.length > 0 ? trimmed : void 0;
153
+ }
154
+ function isValidProductId(productId) {
155
+ return typeof productId === "string" && productId.trim().length > 0;
156
+ }
157
+ function isValidCheckoutUrl(url) {
158
+ return url.startsWith("https://") || url.startsWith("http://");
159
+ }
160
+ function isProductionInsecureUrl(url) {
161
+ return url.startsWith("http://") && !url.includes("localhost") && !url.includes("127.0.0.1");
162
+ }
132
163
 
133
164
  // src/presentation/components/PolarProvider.tsx
134
165
  import {
@@ -155,67 +186,72 @@ var FREE_STATUS = {
155
186
  plan: "free",
156
187
  subscriptionStatus: "none"
157
188
  };
158
- function normalizeUserId(userId) {
159
- if (typeof userId !== "string") return void 0;
160
- const trimmed = userId.trim();
161
- return trimmed.length > 0 ? trimmed : void 0;
162
- }
163
189
  function PolarProvider({ adapter, userId, children }) {
164
190
  const [status, setStatus] = useState(FREE_STATUS);
165
191
  const [loading, setLoading] = useState(true);
166
192
  const adapterRef = useRef(adapter);
193
+ const statusRef = useRef({ setStatus, setLoading });
194
+ const userIdRef = useRef(normalizeUserId(userId));
195
+ const refreshMountedRef = useRef(true);
167
196
  adapterRef.current = adapter;
168
- const refreshAbortRef = useRef(null);
197
+ statusRef.current = { setStatus, setLoading };
198
+ userIdRef.current = normalizeUserId(userId);
169
199
  const refresh = useCallback(async () => {
170
- const uid = normalizeUserId(userId);
200
+ const uid = userIdRef.current;
201
+ const { setStatus: setStatus2, setLoading: setLoading2 } = statusRef.current;
171
202
  if (!uid) {
172
- setStatus(FREE_STATUS);
173
- setLoading(false);
203
+ setStatus2(FREE_STATUS);
204
+ setLoading2(false);
174
205
  return;
175
206
  }
176
- refreshAbortRef.current?.abort();
177
- const ctrl = new AbortController();
178
- refreshAbortRef.current = ctrl;
207
+ refreshMountedRef.current = true;
179
208
  try {
180
- setLoading(true);
209
+ setLoading2(true);
181
210
  const s = await adapterRef.current.getStatus(uid);
182
- if (!ctrl.signal.aborted) setStatus(s);
211
+ if (refreshMountedRef.current) setStatus2(s);
183
212
  } catch (err) {
184
- if (!ctrl.signal.aborted) {
213
+ if (refreshMountedRef.current) {
185
214
  console.error("[polar-billing] getStatus failed:", err);
186
- setStatus(FREE_STATUS);
215
+ setStatus2(FREE_STATUS);
187
216
  }
188
217
  } finally {
189
- if (!ctrl.signal.aborted) setLoading(false);
218
+ if (refreshMountedRef.current) setLoading2(false);
190
219
  }
191
- }, [userId]);
220
+ }, []);
192
221
  useEffect(() => {
222
+ refreshMountedRef.current = true;
193
223
  refresh();
194
224
  return () => {
195
- refreshAbortRef.current?.abort();
225
+ refreshMountedRef.current = false;
196
226
  };
197
- }, [refresh]);
227
+ }, [userId]);
198
228
  const startCheckout = useCallback(async (params) => {
199
- const uid = normalizeUserId(userId);
200
- const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
201
- if (!result.url.startsWith("https://")) {
202
- throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https://");
229
+ if (!isValidProductId(params.productId)) {
230
+ throw new Error("[polar-billing] Invalid productId: must be a non-empty string");
231
+ }
232
+ const uid = userIdRef.current;
233
+ const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? void 0 });
234
+ if (!isValidCheckoutUrl(result.url)) {
235
+ throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https:// or http://");
236
+ }
237
+ if (isProductionInsecureUrl(result.url)) {
238
+ console.warn("[polar-billing] WARNING: Using insecure http:// URL in production environment");
203
239
  }
204
240
  window.location.href = result.url;
205
- }, [userId]);
241
+ }, []);
206
242
  const syncSubscription = useCallback(async () => {
207
- const uid = normalizeUserId(userId);
243
+ const uid = userIdRef.current;
208
244
  if (!uid) return { synced: false };
209
245
  const checkoutId = new URLSearchParams(window.location.search).get("checkout_id") ?? void 0;
210
246
  const result = await adapterRef.current.syncSubscription(uid, checkoutId);
211
247
  if (result.synced) await refresh();
212
248
  return result;
213
- }, [userId, refresh]);
249
+ }, [refresh]);
214
250
  const getBillingHistory = useCallback(async () => {
215
- const uid = normalizeUserId(userId);
251
+ const uid = userIdRef.current;
216
252
  if (!uid) return [];
217
253
  return adapterRef.current.getBillingHistory(uid);
218
- }, [userId]);
254
+ }, []);
219
255
  const cancelSubscription = useCallback(
220
256
  async (reason) => {
221
257
  const result = await adapterRef.current.cancelSubscription(reason);
@@ -225,10 +261,10 @@ function PolarProvider({ adapter, userId, children }) {
225
261
  [refresh]
226
262
  );
227
263
  const getPortalUrl = useCallback(async () => {
228
- const uid = normalizeUserId(userId);
264
+ const uid = userIdRef.current;
229
265
  if (!uid) throw new Error("[polar-billing] Cannot get portal URL: No authenticated user");
230
266
  return adapterRef.current.getPortalUrl(uid);
231
- }, [userId]);
267
+ }, []);
232
268
  const value = useMemo(
233
269
  () => ({
234
270
  status,
@@ -240,7 +276,7 @@ function PolarProvider({ adapter, userId, children }) {
240
276
  cancelSubscription,
241
277
  getPortalUrl
242
278
  }),
243
- [status, loading, refresh, startCheckout, syncSubscription, getBillingHistory, cancelSubscription, getPortalUrl]
279
+ [status, loading, refresh, syncSubscription]
244
280
  );
245
281
  return /* @__PURE__ */ jsx(PolarContext.Provider, { value, children });
246
282
  }
@@ -249,9 +285,16 @@ export {
249
285
  PolarContext,
250
286
  PolarProvider,
251
287
  SUBSCRIPTION_STATUS,
288
+ asBoolean,
289
+ asString,
252
290
  createFirebaseAdapter,
291
+ isProductionInsecureUrl,
292
+ isTimestamp,
293
+ isValidCheckoutUrl,
294
+ isValidProductId,
253
295
  normalizeBillingCycle,
254
296
  normalizeStatus,
297
+ normalizeUserId,
255
298
  usePolarBilling,
256
299
  useSubscription
257
300
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-polar-payment",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Universal Polar.sh subscription billing — Firebase adapter",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -1,8 +1,3 @@
1
- /**
2
- * Cancellation Entity
3
- * @description Types for subscription cancellation reasons and outcomes
4
- */
5
-
6
1
  export type CancellationReason =
7
2
  | 'too_expensive'
8
3
  | 'missing_features'
@@ -1,14 +1,8 @@
1
- /**
2
- * Checkout Entity
3
- * @description Types for initiating and following process of checkouts
4
- */
5
-
6
1
  export interface CheckoutParams {
7
2
  productId: string;
8
3
  planKey?: string;
9
4
  billingCycle?: 'monthly' | 'yearly';
10
5
  successUrl?: string;
11
- /** Injected automatically by PolarProvider — do not pass manually */
12
6
  userId?: string;
13
7
  }
14
8
 
@@ -1,8 +1,3 @@
1
- /**
2
- * Order Entity
3
- * @description Types for billing history items
4
- */
5
-
6
1
  export interface OrderItem {
7
2
  id: string;
8
3
  createdAt: string;
@@ -1,8 +1,3 @@
1
- /**
2
- * Subscription Entity
3
- * @description Types for subscription status and billing cycles
4
- */
5
-
6
1
  export type SubscriptionStatusValue =
7
2
  | 'active'
8
3
  | 'canceled'
@@ -24,6 +19,5 @@ export interface SubscriptionStatus {
24
19
  currentPeriodEnd?: string;
25
20
  billingCycle?: BillingCycle;
26
21
  polarCustomerId?: string;
27
- /** Token balance (for token-based projects like Aria) */
28
22
  tokens?: number;
29
23
  }
@@ -1,8 +1,3 @@
1
- /**
2
- * Sync Entity
3
- * @description Type for subscription synchronization results
4
- */
5
-
6
1
  export interface SyncResult {
7
2
  synced: boolean;
8
3
  plan?: string;
@@ -1,7 +1,2 @@
1
- /**
2
- * Domain Layer
3
- * Subpath: @umituz/web-polar-payment/domain
4
- */
5
-
6
1
  export * from './entities';
7
2
  export * from './interfaces';
package/src/index.ts CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * @umituz/web-polar-payment
3
- * Universal Polar.sh subscription billing — Firebase adapter
4
- *
5
- * IMPORTANT: Apps should NOT use this root barrel export.
6
- * Use subpath imports instead: "@umituz/web-polar-payment/domain"
7
- */
8
-
9
1
  export * from './domain';
10
2
  export * from './infrastructure';
11
3
  export * from './presentation';
@@ -1,9 +1,4 @@
1
- /**
2
- * Billing Constants
3
- * @description Standardized subscription states and plan names
4
- */
5
-
6
- export const SUBSCRIPTION_STATUS = {
1
+ export const SUBSCRIPTION_STATUS = Object.freeze({
7
2
  ACTIVE: 'active' as const,
8
3
  CANCELED: 'canceled' as const,
9
4
  REVOKED: 'revoked' as const,
@@ -13,6 +8,16 @@ export const SUBSCRIPTION_STATUS = {
13
8
  INCOMPLETE_EXPIRED: 'incomplete_expired' as const,
14
9
  UNPAID: 'unpaid' as const,
15
10
  NONE: 'none' as const,
16
- };
11
+ }) as Readonly<{
12
+ ACTIVE: 'active';
13
+ CANCELED: 'canceled';
14
+ REVOKED: 'revoked';
15
+ TRIALING: 'trialing';
16
+ PAST_DUE: 'past_due';
17
+ INCOMPLETE: 'incomplete';
18
+ INCOMPLETE_EXPIRED: 'incomplete_expired';
19
+ UNPAID: 'unpaid';
20
+ NONE: 'none';
21
+ }>;
17
22
 
18
23
  export const FREE_PLAN = 'free';
@@ -1,8 +1,5 @@
1
- /**
2
- * Infrastructure Layer
3
- * Subpath: @umituz/web-polar-payment/infrastructure
4
- */
5
-
6
1
  export * from './services/firebase-billing.service';
7
2
  export * from './constants/billing.constants';
8
3
  export * from './utils/normalization.util';
4
+ export * from './utils/validations.util';
5
+ export * from './utils/firebase-helpers.util';
@@ -9,37 +9,8 @@ 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
14
  type FirebaseFunctions = any;
44
15
  type FirebaseFirestore = any;
45
16
 
@@ -67,12 +38,7 @@ 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
42
  const functions = config.functions as FirebaseFunctions;
77
43
  const firestore = config.firestore as FirebaseFirestore;
78
44
 
@@ -95,8 +61,27 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
95
61
  currentPeriodEnd: config.db?.currentPeriodEndField ?? 'currentPeriodEnd',
96
62
  };
97
63
 
64
+ let httpsCallableCache: typeof import('firebase/functions')['httpsCallable'] | null = null;
65
+ let firestoreCache: { doc: any; getDoc: any } | null = null;
66
+
67
+ async function getHttpsCallable() {
68
+ if (!httpsCallableCache) {
69
+ const mod = await import('firebase/functions');
70
+ httpsCallableCache = mod.httpsCallable;
71
+ }
72
+ return httpsCallableCache;
73
+ }
74
+
75
+ async function getFirestore() {
76
+ if (!firestoreCache) {
77
+ const mod = await import('firebase/firestore');
78
+ firestoreCache = { doc: mod.doc, getDoc: mod.getDoc };
79
+ }
80
+ return firestoreCache;
81
+ }
82
+
98
83
  async function callable<T = unknown, R = unknown>(name: string, data?: T): Promise<R> {
99
- const { httpsCallable } = await import('firebase/functions');
84
+ const httpsCallable = await getHttpsCallable();
100
85
  const fn = (httpsCallable as any)(functions, name) as (data?: T) => Promise<{ data: R }>;
101
86
  const result = await fn(data);
102
87
  return result.data;
@@ -104,7 +89,7 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
104
89
 
105
90
  return {
106
91
  async getStatus(userId: string): Promise<SubscriptionStatus> {
107
- const { doc, getDoc } = await import('firebase/firestore');
92
+ const { doc, getDoc } = await getFirestore();
108
93
  const snap = await getDoc((doc as any)(firestore, db.collection, userId));
109
94
 
110
95
  if (!snap.exists()) {
@@ -155,13 +140,11 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
155
140
  },
156
141
 
157
142
  async getPortalUrl(_userId: string): Promise<string> {
158
- const result = await callable<Record<string, never>, { url?: string; customerPortalUrl?: string }>(
143
+ const result = await callable<Record<string, never>, { url: string }>(
159
144
  callables.portal,
160
145
  {},
161
146
  );
162
- const url = result.url ?? result.customerPortalUrl;
163
- if (!url) throw new Error('No portal URL returned from Cloud Function');
164
- return url;
147
+ return result.url;
165
148
  },
166
149
  };
167
150
  }
@@ -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
+ typeof (value as { toDate: unknown }).toDate === 'function'
17
+ );
18
+ }
@@ -1,31 +1,23 @@
1
1
  import type { SubscriptionStatusValue, BillingCycle } from '../../domain/entities';
2
+ import { SUBSCRIPTION_STATUS } from '../constants/billing.constants';
2
3
 
3
- /**
4
- * Normalize a raw Polar status string to a known value.
5
- * @description Defaults to 'none' for unknown statuses or non-string input.
6
- */
7
- export function normalizeStatus(raw: string): SubscriptionStatusValue {
8
- const map: Record<string, SubscriptionStatusValue> = {
9
- active: 'active',
10
- trialing: 'trialing',
11
- past_due: 'past_due',
12
- incomplete: 'incomplete',
13
- incomplete_expired: 'incomplete_expired',
14
- unpaid: 'unpaid',
15
- canceled: 'canceled',
16
- cancelled: 'canceled',
17
- revoked: 'revoked',
18
- none: 'none',
19
- };
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
+ });
20
15
 
21
- if (typeof raw !== 'string') return 'none';
22
- return map[raw.toLowerCase()] ?? 'none';
16
+ export function normalizeStatus(raw: string): SubscriptionStatusValue {
17
+ if (raw == null || typeof raw !== 'string') return 'none';
18
+ return STATUS_MAP[raw.toLowerCase()] ?? 'none';
23
19
  }
24
20
 
25
- /**
26
- * Normalize billing interval
27
- * @description Maps 'month'/'year' to 'monthly'/'yearly'. Defaults to 'monthly' for unknown values or non-string input.
28
- */
29
21
  export function normalizeBillingCycle(interval: string): BillingCycle {
30
22
  if (typeof interval !== 'string') return 'monthly';
31
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
+ }