@umituz/web-polar-payment 1.0.5 → 1.0.7

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.d.mts CHANGED
@@ -85,11 +85,11 @@ interface PolarAdapter {
85
85
  getPortalUrl(userId: string): Promise<string>;
86
86
  }
87
87
 
88
- type FirebaseFunctions = any;
89
- type FirebaseFirestore = any;
90
88
  interface FirebaseAdapterConfig {
91
- functions: FirebaseFunctions;
92
- firestore: FirebaseFirestore;
89
+ /** Firebase Functions instance from firebase/functions */
90
+ functions: unknown;
91
+ /** Firebase Firestore instance from firebase/firestore */
92
+ firestore: unknown;
93
93
  callables?: {
94
94
  createCheckout?: string;
95
95
  syncSubscription?: string;
@@ -133,12 +133,12 @@ declare const FREE_PLAN = "free";
133
133
 
134
134
  /**
135
135
  * Normalize a raw Polar status string to a known value.
136
- * @description Defaults to 'none' for unknown statuses.
136
+ * @description Defaults to 'none' for unknown statuses or non-string input.
137
137
  */
138
138
  declare function normalizeStatus(raw: string): SubscriptionStatusValue;
139
139
  /**
140
140
  * Normalize billing interval
141
- * @description Maps 'month'/'year' to 'monthly'/'yearly'
141
+ * @description Maps 'month'/'year' to 'monthly'/'yearly'. Defaults to 'monthly' for unknown values or non-string input.
142
142
  */
143
143
  declare function normalizeBillingCycle(interval: string): BillingCycle;
144
144
 
package/dist/index.d.ts CHANGED
@@ -85,11 +85,11 @@ interface PolarAdapter {
85
85
  getPortalUrl(userId: string): Promise<string>;
86
86
  }
87
87
 
88
- type FirebaseFunctions = any;
89
- type FirebaseFirestore = any;
90
88
  interface FirebaseAdapterConfig {
91
- functions: FirebaseFunctions;
92
- firestore: FirebaseFirestore;
89
+ /** Firebase Functions instance from firebase/functions */
90
+ functions: unknown;
91
+ /** Firebase Firestore instance from firebase/firestore */
92
+ firestore: unknown;
93
93
  callables?: {
94
94
  createCheckout?: string;
95
95
  syncSubscription?: string;
@@ -133,12 +133,12 @@ declare const FREE_PLAN = "free";
133
133
 
134
134
  /**
135
135
  * Normalize a raw Polar status string to a known value.
136
- * @description Defaults to 'none' for unknown statuses.
136
+ * @description Defaults to 'none' for unknown statuses or non-string input.
137
137
  */
138
138
  declare function normalizeStatus(raw: string): SubscriptionStatusValue;
139
139
  /**
140
140
  * Normalize billing interval
141
- * @description Maps 'month'/'year' to 'monthly'/'yearly'
141
+ * @description Maps 'month'/'year' to 'monthly'/'yearly'. Defaults to 'monthly' for unknown values or non-string input.
142
142
  */
143
143
  declare function normalizeBillingCycle(interval: string): BillingCycle;
144
144
 
package/dist/index.js CHANGED
@@ -56,16 +56,32 @@ function normalizeStatus(raw) {
56
56
  revoked: "revoked",
57
57
  none: "none"
58
58
  };
59
- return map[raw?.toLowerCase()] ?? "none";
59
+ if (typeof raw !== "string") return "none";
60
+ return map[raw.toLowerCase()] ?? "none";
60
61
  }
61
62
  function normalizeBillingCycle(interval) {
62
- if (interval === "month" || interval === "monthly") return "monthly";
63
- if (interval === "year" || interval === "yearly") return "yearly";
63
+ if (typeof interval !== "string") return "monthly";
64
+ const normalized = interval.toLowerCase();
65
+ if (normalized === "month" || normalized === "monthly") return "monthly";
66
+ if (normalized === "year" || normalized === "yearly") return "yearly";
64
67
  return "monthly";
65
68
  }
66
69
 
67
70
  // src/infrastructure/services/firebase-billing.service.ts
71
+ function asString(value) {
72
+ if (typeof value === "string") return value;
73
+ return void 0;
74
+ }
75
+ function asBoolean(value) {
76
+ if (typeof value === "boolean") return value;
77
+ return void 0;
78
+ }
79
+ function isTimestamp(value) {
80
+ return typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function";
81
+ }
68
82
  function createFirebaseAdapter(config) {
83
+ const functions = config.functions;
84
+ const firestore = config.firestore;
69
85
  const callables = {
70
86
  createCheckout: config.callables?.createCheckout ?? "createCheckoutSession",
71
87
  sync: config.callables?.syncSubscription ?? "syncSubscription",
@@ -85,14 +101,14 @@ function createFirebaseAdapter(config) {
85
101
  };
86
102
  async function callable(name, data) {
87
103
  const { httpsCallable } = await import("firebase/functions");
88
- const fn = httpsCallable(config.functions, name);
104
+ const fn = httpsCallable(functions, name);
89
105
  const result = await fn(data);
90
106
  return result.data;
91
107
  }
92
108
  return {
93
109
  async getStatus(userId) {
94
110
  const { doc, getDoc } = await import("firebase/firestore");
95
- const snap = await getDoc(doc(config.firestore, db.collection, userId));
111
+ const snap = await getDoc(doc(firestore, db.collection, userId));
96
112
  if (!snap.exists()) {
97
113
  return { plan: "free", subscriptionStatus: "none" };
98
114
  }
@@ -100,19 +116,19 @@ function createFirebaseAdapter(config) {
100
116
  let currentPeriodEnd;
101
117
  const rawEnd = d[db.currentPeriodEnd];
102
118
  if (rawEnd != null) {
103
- if (typeof rawEnd === "object" && "toDate" in rawEnd) {
119
+ if (isTimestamp(rawEnd)) {
104
120
  currentPeriodEnd = rawEnd.toDate().toISOString();
105
121
  } else if (typeof rawEnd === "string") {
106
122
  currentPeriodEnd = rawEnd;
107
123
  }
108
124
  }
109
125
  return {
110
- plan: d[db.plan] ?? "free",
111
- billingCycle: normalizeBillingCycle(d[db.billingCycle] ?? "monthly"),
112
- subscriptionId: d[db.subscriptionId],
113
- subscriptionStatus: normalizeStatus(d[db.subscriptionStatus] ?? "none"),
114
- polarCustomerId: d[db.polarCustomerId],
115
- cancelAtPeriodEnd: d[db.cancelAtPeriodEnd],
126
+ plan: asString(d[db.plan]) ?? "free",
127
+ billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? "monthly"),
128
+ subscriptionId: asString(d[db.subscriptionId]),
129
+ subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? "none"),
130
+ polarCustomerId: asString(d[db.polarCustomerId]),
131
+ cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
116
132
  currentPeriodEnd
117
133
  };
118
134
  },
@@ -177,6 +193,11 @@ var FREE_STATUS = {
177
193
  plan: "free",
178
194
  subscriptionStatus: "none"
179
195
  };
196
+ function normalizeUserId(userId) {
197
+ if (typeof userId !== "string") return void 0;
198
+ const trimmed = userId.trim();
199
+ return trimmed.length > 0 ? trimmed : void 0;
200
+ }
180
201
  function PolarProvider({ adapter, userId, children }) {
181
202
  const [status, setStatus] = (0, import_react2.useState)(FREE_STATUS);
182
203
  const [loading, setLoading] = (0, import_react2.useState)(true);
@@ -184,7 +205,7 @@ function PolarProvider({ adapter, userId, children }) {
184
205
  adapterRef.current = adapter;
185
206
  const refreshAbortRef = (0, import_react2.useRef)(null);
186
207
  const refresh = (0, import_react2.useCallback)(async () => {
187
- const uid = userId?.trim();
208
+ const uid = normalizeUserId(userId);
188
209
  if (!uid) {
189
210
  setStatus(FREE_STATUS);
190
211
  setLoading(false);
@@ -213,14 +234,15 @@ function PolarProvider({ adapter, userId, children }) {
213
234
  };
214
235
  }, [refresh]);
215
236
  const startCheckout = (0, import_react2.useCallback)(async (params) => {
216
- const result = await adapterRef.current.createCheckout({ ...params, userId: userId?.trim() });
237
+ const uid = normalizeUserId(userId);
238
+ const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
217
239
  if (!result.url.startsWith("https://")) {
218
- throw new Error("Invalid checkout URL returned");
240
+ throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https://");
219
241
  }
220
242
  window.location.href = result.url;
221
243
  }, [userId]);
222
244
  const syncSubscription = (0, import_react2.useCallback)(async () => {
223
- const uid = userId?.trim();
245
+ const uid = normalizeUserId(userId);
224
246
  if (!uid) return { synced: false };
225
247
  const checkoutId = new URLSearchParams(window.location.search).get("checkout_id") ?? void 0;
226
248
  const result = await adapterRef.current.syncSubscription(uid, checkoutId);
@@ -228,7 +250,7 @@ function PolarProvider({ adapter, userId, children }) {
228
250
  return result;
229
251
  }, [userId, refresh]);
230
252
  const getBillingHistory = (0, import_react2.useCallback)(async () => {
231
- const uid = userId?.trim();
253
+ const uid = normalizeUserId(userId);
232
254
  if (!uid) return [];
233
255
  return adapterRef.current.getBillingHistory(uid);
234
256
  }, [userId]);
@@ -241,8 +263,8 @@ function PolarProvider({ adapter, userId, children }) {
241
263
  [refresh]
242
264
  );
243
265
  const getPortalUrl = (0, import_react2.useCallback)(async () => {
244
- const uid = userId?.trim();
245
- if (!uid) throw new Error("No authenticated user");
266
+ const uid = normalizeUserId(userId);
267
+ if (!uid) throw new Error("[polar-billing] Cannot get portal URL: No authenticated user");
246
268
  return adapterRef.current.getPortalUrl(uid);
247
269
  }, [userId]);
248
270
  const value = (0, import_react2.useMemo)(
package/dist/index.mjs CHANGED
@@ -12,16 +12,32 @@ function normalizeStatus(raw) {
12
12
  revoked: "revoked",
13
13
  none: "none"
14
14
  };
15
- return map[raw?.toLowerCase()] ?? "none";
15
+ if (typeof raw !== "string") return "none";
16
+ return map[raw.toLowerCase()] ?? "none";
16
17
  }
17
18
  function normalizeBillingCycle(interval) {
18
- if (interval === "month" || interval === "monthly") return "monthly";
19
- if (interval === "year" || interval === "yearly") return "yearly";
19
+ if (typeof interval !== "string") return "monthly";
20
+ const normalized = interval.toLowerCase();
21
+ if (normalized === "month" || normalized === "monthly") return "monthly";
22
+ if (normalized === "year" || normalized === "yearly") return "yearly";
20
23
  return "monthly";
21
24
  }
22
25
 
23
26
  // src/infrastructure/services/firebase-billing.service.ts
27
+ function asString(value) {
28
+ if (typeof value === "string") return value;
29
+ return void 0;
30
+ }
31
+ function asBoolean(value) {
32
+ if (typeof value === "boolean") return value;
33
+ return void 0;
34
+ }
35
+ function isTimestamp(value) {
36
+ return typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function";
37
+ }
24
38
  function createFirebaseAdapter(config) {
39
+ const functions = config.functions;
40
+ const firestore = config.firestore;
25
41
  const callables = {
26
42
  createCheckout: config.callables?.createCheckout ?? "createCheckoutSession",
27
43
  sync: config.callables?.syncSubscription ?? "syncSubscription",
@@ -41,14 +57,14 @@ function createFirebaseAdapter(config) {
41
57
  };
42
58
  async function callable(name, data) {
43
59
  const { httpsCallable } = await import("firebase/functions");
44
- const fn = httpsCallable(config.functions, name);
60
+ const fn = httpsCallable(functions, name);
45
61
  const result = await fn(data);
46
62
  return result.data;
47
63
  }
48
64
  return {
49
65
  async getStatus(userId) {
50
66
  const { doc, getDoc } = await import("firebase/firestore");
51
- const snap = await getDoc(doc(config.firestore, db.collection, userId));
67
+ const snap = await getDoc(doc(firestore, db.collection, userId));
52
68
  if (!snap.exists()) {
53
69
  return { plan: "free", subscriptionStatus: "none" };
54
70
  }
@@ -56,19 +72,19 @@ function createFirebaseAdapter(config) {
56
72
  let currentPeriodEnd;
57
73
  const rawEnd = d[db.currentPeriodEnd];
58
74
  if (rawEnd != null) {
59
- if (typeof rawEnd === "object" && "toDate" in rawEnd) {
75
+ if (isTimestamp(rawEnd)) {
60
76
  currentPeriodEnd = rawEnd.toDate().toISOString();
61
77
  } else if (typeof rawEnd === "string") {
62
78
  currentPeriodEnd = rawEnd;
63
79
  }
64
80
  }
65
81
  return {
66
- plan: d[db.plan] ?? "free",
67
- billingCycle: normalizeBillingCycle(d[db.billingCycle] ?? "monthly"),
68
- subscriptionId: d[db.subscriptionId],
69
- subscriptionStatus: normalizeStatus(d[db.subscriptionStatus] ?? "none"),
70
- polarCustomerId: d[db.polarCustomerId],
71
- cancelAtPeriodEnd: d[db.cancelAtPeriodEnd],
82
+ plan: asString(d[db.plan]) ?? "free",
83
+ billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? "monthly"),
84
+ subscriptionId: asString(d[db.subscriptionId]),
85
+ subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? "none"),
86
+ polarCustomerId: asString(d[db.polarCustomerId]),
87
+ cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
72
88
  currentPeriodEnd
73
89
  };
74
90
  },
@@ -139,6 +155,11 @@ var FREE_STATUS = {
139
155
  plan: "free",
140
156
  subscriptionStatus: "none"
141
157
  };
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
+ }
142
163
  function PolarProvider({ adapter, userId, children }) {
143
164
  const [status, setStatus] = useState(FREE_STATUS);
144
165
  const [loading, setLoading] = useState(true);
@@ -146,7 +167,7 @@ function PolarProvider({ adapter, userId, children }) {
146
167
  adapterRef.current = adapter;
147
168
  const refreshAbortRef = useRef(null);
148
169
  const refresh = useCallback(async () => {
149
- const uid = userId?.trim();
170
+ const uid = normalizeUserId(userId);
150
171
  if (!uid) {
151
172
  setStatus(FREE_STATUS);
152
173
  setLoading(false);
@@ -175,14 +196,15 @@ function PolarProvider({ adapter, userId, children }) {
175
196
  };
176
197
  }, [refresh]);
177
198
  const startCheckout = useCallback(async (params) => {
178
- const result = await adapterRef.current.createCheckout({ ...params, userId: userId?.trim() });
199
+ const uid = normalizeUserId(userId);
200
+ const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
179
201
  if (!result.url.startsWith("https://")) {
180
- throw new Error("Invalid checkout URL returned");
202
+ throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https://");
181
203
  }
182
204
  window.location.href = result.url;
183
205
  }, [userId]);
184
206
  const syncSubscription = useCallback(async () => {
185
- const uid = userId?.trim();
207
+ const uid = normalizeUserId(userId);
186
208
  if (!uid) return { synced: false };
187
209
  const checkoutId = new URLSearchParams(window.location.search).get("checkout_id") ?? void 0;
188
210
  const result = await adapterRef.current.syncSubscription(uid, checkoutId);
@@ -190,7 +212,7 @@ function PolarProvider({ adapter, userId, children }) {
190
212
  return result;
191
213
  }, [userId, refresh]);
192
214
  const getBillingHistory = useCallback(async () => {
193
- const uid = userId?.trim();
215
+ const uid = normalizeUserId(userId);
194
216
  if (!uid) return [];
195
217
  return adapterRef.current.getBillingHistory(uid);
196
218
  }, [userId]);
@@ -203,8 +225,8 @@ function PolarProvider({ adapter, userId, children }) {
203
225
  [refresh]
204
226
  );
205
227
  const getPortalUrl = useCallback(async () => {
206
- const uid = userId?.trim();
207
- if (!uid) throw new Error("No authenticated user");
228
+ const uid = normalizeUserId(userId);
229
+ if (!uid) throw new Error("[polar-billing] Cannot get portal URL: No authenticated user");
208
230
  return adapterRef.current.getPortalUrl(uid);
209
231
  }, [userId]);
210
232
  const value = useMemo(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@umituz/web-polar-payment",
3
- "version": "1.0.5",
4
- "description": "Universal Polar.sh subscription billing — Supabase & Firebase adapters",
3
+ "version": "1.0.7",
4
+ "description": "Universal Polar.sh subscription billing — Firebase adapter",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
@@ -20,7 +20,8 @@
20
20
  "./presentation": "./src/presentation/index.ts"
21
21
  },
22
22
  "files": [
23
- "dist"
23
+ "dist",
24
+ "src"
24
25
  ],
25
26
  "scripts": {
26
27
  "build": "tsup src/index.ts --format cjs,esm --dts --clean --external firebase --external firebase/functions --external firebase/firestore",
@@ -0,0 +1,2 @@
1
+ declare module 'firebase/functions';
2
+ declare module 'firebase/firestore';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cancellation Entity
3
+ * @description Types for subscription cancellation reasons and outcomes
4
+ */
5
+
6
+ export type CancellationReason =
7
+ | 'too_expensive'
8
+ | 'missing_features'
9
+ | 'switched_service'
10
+ | 'unused'
11
+ | 'customer_service'
12
+ | 'low_quality'
13
+ | 'too_complex'
14
+ | 'other';
15
+
16
+ export interface CancelResult {
17
+ success: boolean;
18
+ endsAt?: string;
19
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Checkout Entity
3
+ * @description Types for initiating and following process of checkouts
4
+ */
5
+
6
+ export interface CheckoutParams {
7
+ productId: string;
8
+ planKey?: string;
9
+ billingCycle?: 'monthly' | 'yearly';
10
+ successUrl?: string;
11
+ /** Injected automatically by PolarProvider — do not pass manually */
12
+ userId?: string;
13
+ }
14
+
15
+ export interface CheckoutResult {
16
+ url: string;
17
+ id: string;
18
+ }
@@ -0,0 +1,5 @@
1
+ export * from './subscription.entity';
2
+ export * from './order.entity';
3
+ export * from './checkout.entity';
4
+ export * from './cancellation.entity';
5
+ export * from './sync.entity';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Order Entity
3
+ * @description Types for billing history items
4
+ */
5
+
6
+ export interface OrderItem {
7
+ id: string;
8
+ createdAt: string;
9
+ amount: number;
10
+ currency: string;
11
+ status: string;
12
+ paid: boolean;
13
+ productName: string;
14
+ invoiceUrl?: string;
15
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Subscription Entity
3
+ * @description Types for subscription status and billing cycles
4
+ */
5
+
6
+ export type SubscriptionStatusValue =
7
+ | 'active'
8
+ | 'canceled'
9
+ | 'revoked'
10
+ | 'trialing'
11
+ | 'past_due'
12
+ | 'incomplete'
13
+ | 'incomplete_expired'
14
+ | 'unpaid'
15
+ | 'none';
16
+
17
+ export type BillingCycle = 'monthly' | 'yearly';
18
+
19
+ export interface SubscriptionStatus {
20
+ plan: string;
21
+ subscriptionId?: string;
22
+ subscriptionStatus: SubscriptionStatusValue;
23
+ cancelAtPeriodEnd?: boolean;
24
+ currentPeriodEnd?: string;
25
+ billingCycle?: BillingCycle;
26
+ polarCustomerId?: string;
27
+ /** Token balance (for token-based projects like Aria) */
28
+ tokens?: number;
29
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Sync Entity
3
+ * @description Type for subscription synchronization results
4
+ */
5
+
6
+ export interface SyncResult {
7
+ synced: boolean;
8
+ plan?: string;
9
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Domain Layer
3
+ * Subpath: @umituz/web-polar-payment/domain
4
+ */
5
+
6
+ export * from './entities';
7
+ export * from './interfaces';
@@ -0,0 +1 @@
1
+ export * from './polar-adapter.interface';
@@ -0,0 +1,23 @@
1
+ import type {
2
+ SubscriptionStatus,
3
+ CheckoutParams,
4
+ CheckoutResult,
5
+ OrderItem,
6
+ CancellationReason,
7
+ CancelResult,
8
+ SyncResult,
9
+ } from '../entities';
10
+
11
+ /**
12
+ * Backend-agnostic interface every adapter must implement.
13
+ * @description Contract for Polar billing adapters (Firebase, Supabase, etc.)
14
+ */
15
+ export interface PolarAdapter {
16
+ getStatus(userId: string): Promise<SubscriptionStatus>;
17
+ createCheckout(params: CheckoutParams): Promise<CheckoutResult>;
18
+ /** checkoutId is read from URL by the context and passed explicitly */
19
+ syncSubscription(userId: string, checkoutId?: string): Promise<SyncResult>;
20
+ getBillingHistory(userId: string): Promise<OrderItem[]>;
21
+ cancelSubscription(reason?: CancellationReason): Promise<CancelResult>;
22
+ getPortalUrl(userId: string): Promise<string>;
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @umituz/web-polar-payment
3
+ * Universal Polar.sh subscription billing — Firebase adapter
4
+ *
5
+ * ONEMLI: App'ler bu root barrel'i kullanMAMALI.
6
+ * Subpath import kullanin: "@umituz/web-polar-payment/domain"
7
+ */
8
+
9
+ export * from './domain';
10
+ export * from './infrastructure';
11
+ export * from './presentation';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Billing Constants
3
+ * @description Standardized subscription states and plan names
4
+ */
5
+
6
+ export const SUBSCRIPTION_STATUS = {
7
+ ACTIVE: 'active' as const,
8
+ CANCELED: 'canceled' as const,
9
+ REVOKED: 'revoked' as const,
10
+ TRIALING: 'trialing' as const,
11
+ PAST_DUE: 'past_due' as const,
12
+ INCOMPLETE: 'incomplete' as const,
13
+ INCOMPLETE_EXPIRED: 'incomplete_expired' as const,
14
+ UNPAID: 'unpaid' as const,
15
+ NONE: 'none' as const,
16
+ };
17
+
18
+ export const FREE_PLAN = 'free';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Infrastructure Layer
3
+ * Subpath: @umituz/web-polar-payment/infrastructure
4
+ */
5
+
6
+ export * from './services/firebase-billing.service';
7
+ export * from './constants/billing.constants';
8
+ export * from './utils/normalization.util';
@@ -0,0 +1,167 @@
1
+ import type { PolarAdapter } from '../../domain/interfaces';
2
+ import type {
3
+ CheckoutParams,
4
+ CheckoutResult,
5
+ OrderItem,
6
+ CancellationReason,
7
+ CancelResult,
8
+ SubscriptionStatus,
9
+ SyncResult,
10
+ } from '../../domain/entities';
11
+ import { normalizeStatus, normalizeBillingCycle } from '../utils/normalization.util';
12
+
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;
45
+
46
+ export interface FirebaseAdapterConfig {
47
+ /** Firebase Functions instance from firebase/functions */
48
+ functions: unknown;
49
+ /** Firebase Firestore instance from firebase/firestore */
50
+ firestore: unknown;
51
+ callables?: {
52
+ createCheckout?: string;
53
+ syncSubscription?: string;
54
+ getBillingHistory?: string;
55
+ cancelSubscription?: string;
56
+ getPortalUrl?: string;
57
+ };
58
+ db?: {
59
+ usersCollection?: string;
60
+ planField?: string;
61
+ billingCycleField?: string;
62
+ subscriptionIdField?: string;
63
+ subscriptionStatusField?: string;
64
+ polarCustomerIdField?: string;
65
+ cancelAtPeriodEndField?: string;
66
+ currentPeriodEndField?: string;
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Firebase Billing Service
72
+ * @description Implementation of PolarAdapter for Firebase Functions and Firestore.
73
+ */
74
+ 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;
78
+
79
+ const callables = {
80
+ createCheckout: config.callables?.createCheckout ?? 'createCheckoutSession',
81
+ sync: config.callables?.syncSubscription ?? 'syncSubscription',
82
+ billing: config.callables?.getBillingHistory ?? 'getBillingHistory',
83
+ cancel: config.callables?.cancelSubscription ?? 'cancelSubscription',
84
+ portal: config.callables?.getPortalUrl ?? 'getCustomerPortalUrl',
85
+ };
86
+
87
+ const db = {
88
+ collection: config.db?.usersCollection ?? 'users',
89
+ plan: config.db?.planField ?? 'plan',
90
+ billingCycle: config.db?.billingCycleField ?? 'billingCycle',
91
+ subscriptionId: config.db?.subscriptionIdField ?? 'subscriptionId',
92
+ subscriptionStatus: config.db?.subscriptionStatusField ?? 'subscriptionStatus',
93
+ polarCustomerId: config.db?.polarCustomerIdField ?? 'polarCustomerId',
94
+ cancelAtPeriodEnd: config.db?.cancelAtPeriodEndField ?? 'cancelAtPeriodEnd',
95
+ currentPeriodEnd: config.db?.currentPeriodEndField ?? 'currentPeriodEnd',
96
+ };
97
+
98
+ async function callable<T = unknown, R = unknown>(name: string, data?: T): Promise<R> {
99
+ const { httpsCallable } = await import('firebase/functions');
100
+ const fn = (httpsCallable as any)(functions, name) as (data?: T) => Promise<{ data: R }>;
101
+ const result = await fn(data);
102
+ return result.data;
103
+ }
104
+
105
+ return {
106
+ async getStatus(userId: string): Promise<SubscriptionStatus> {
107
+ const { doc, getDoc } = await import('firebase/firestore');
108
+ const snap = await getDoc((doc as any)(firestore, db.collection, userId));
109
+
110
+ if (!snap.exists()) {
111
+ return { plan: 'free', subscriptionStatus: 'none' };
112
+ }
113
+
114
+ const d = snap.data() as Record<string, unknown>;
115
+
116
+ let currentPeriodEnd: string | undefined;
117
+ const rawEnd = d[db.currentPeriodEnd];
118
+ if (rawEnd != null) {
119
+ if (isTimestamp(rawEnd)) {
120
+ currentPeriodEnd = rawEnd.toDate().toISOString();
121
+ } else if (typeof rawEnd === 'string') {
122
+ currentPeriodEnd = rawEnd;
123
+ }
124
+ }
125
+
126
+ return {
127
+ plan: asString(d[db.plan]) ?? 'free',
128
+ billingCycle: normalizeBillingCycle(asString(d[db.billingCycle]) ?? 'monthly'),
129
+ subscriptionId: asString(d[db.subscriptionId]),
130
+ subscriptionStatus: normalizeStatus(asString(d[db.subscriptionStatus]) ?? 'none'),
131
+ polarCustomerId: asString(d[db.polarCustomerId]),
132
+ cancelAtPeriodEnd: asBoolean(d[db.cancelAtPeriodEnd]),
133
+ currentPeriodEnd,
134
+ };
135
+ },
136
+
137
+ async createCheckout(params: CheckoutParams): Promise<CheckoutResult> {
138
+ return callable<CheckoutParams, CheckoutResult>(callables.createCheckout, params);
139
+ },
140
+
141
+ async syncSubscription(_userId: string, _checkoutId?: string): Promise<SyncResult> {
142
+ return callable<Record<string, never>, SyncResult>(callables.sync, {});
143
+ },
144
+
145
+ async getBillingHistory(_userId: string): Promise<OrderItem[]> {
146
+ const result = await callable<Record<string, never>, { orders?: OrderItem[] }>(
147
+ callables.billing,
148
+ {},
149
+ );
150
+ return result.orders ?? [];
151
+ },
152
+
153
+ async cancelSubscription(reason?: CancellationReason): Promise<CancelResult> {
154
+ return callable<{ reason?: string }, CancelResult>(callables.cancel, { reason });
155
+ },
156
+
157
+ async getPortalUrl(_userId: string): Promise<string> {
158
+ const result = await callable<Record<string, never>, { url?: string; customerPortalUrl?: string }>(
159
+ callables.portal,
160
+ {},
161
+ );
162
+ const url = result.url ?? result.customerPortalUrl;
163
+ if (!url) throw new Error('No portal URL returned from Cloud Function');
164
+ return url;
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,35 @@
1
+ import type { SubscriptionStatusValue, BillingCycle } from '../../domain/entities';
2
+
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
+ };
20
+
21
+ if (typeof raw !== 'string') return 'none';
22
+ return map[raw.toLowerCase()] ?? 'none';
23
+ }
24
+
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
+ export function normalizeBillingCycle(interval: string): BillingCycle {
30
+ if (typeof interval !== 'string') return 'monthly';
31
+ const normalized = interval.toLowerCase();
32
+ if (normalized === 'month' || normalized === 'monthly') return 'monthly';
33
+ if (normalized === 'year' || normalized === 'yearly') return 'yearly';
34
+ return 'monthly';
35
+ }
@@ -0,0 +1,135 @@
1
+ import {
2
+ useEffect,
3
+ useState,
4
+ useCallback,
5
+ useRef,
6
+ useMemo,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import type { PolarAdapter } from '../../domain/interfaces';
10
+ import type {
11
+ SubscriptionStatus,
12
+ CheckoutParams,
13
+ CancellationReason,
14
+ CancelResult,
15
+ SyncResult,
16
+ OrderItem,
17
+ } from '../../domain/entities';
18
+ import { PolarContext } from '../hooks/usePolarBilling';
19
+
20
+ /**
21
+ * PolarProvider Component
22
+ * @description Context provider for Polar billing management.
23
+ */
24
+
25
+ interface PolarProviderProps {
26
+ adapter: PolarAdapter;
27
+ userId?: string;
28
+ children: ReactNode;
29
+ }
30
+
31
+ const FREE_STATUS: SubscriptionStatus = {
32
+ plan: 'free',
33
+ subscriptionStatus: 'none',
34
+ };
35
+
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
+ export function PolarProvider({ adapter, userId, children }: PolarProviderProps) {
43
+ const [status, setStatus] = useState<SubscriptionStatus>(FREE_STATUS);
44
+ const [loading, setLoading] = useState(true);
45
+ const adapterRef = useRef(adapter);
46
+ adapterRef.current = adapter;
47
+ const refreshAbortRef = useRef<AbortController | null>(null);
48
+
49
+ const refresh = useCallback(async () => {
50
+ const uid = normalizeUserId(userId);
51
+ if (!uid) {
52
+ setStatus(FREE_STATUS);
53
+ setLoading(false);
54
+ return;
55
+ }
56
+
57
+ refreshAbortRef.current?.abort();
58
+ const ctrl = new AbortController();
59
+ refreshAbortRef.current = ctrl;
60
+
61
+ try {
62
+ setLoading(true);
63
+ const s = await adapterRef.current.getStatus(uid);
64
+ if (!ctrl.signal.aborted) setStatus(s);
65
+ } catch (err) {
66
+ if (!ctrl.signal.aborted) {
67
+ console.error('[polar-billing] getStatus failed:', err);
68
+ setStatus(FREE_STATUS);
69
+ }
70
+ } finally {
71
+ if (!ctrl.signal.aborted) setLoading(false);
72
+ }
73
+ }, [userId]);
74
+
75
+ useEffect(() => {
76
+ refresh();
77
+ return () => { refreshAbortRef.current?.abort(); };
78
+ }, [refresh]);
79
+
80
+ const startCheckout = useCallback(async (params: CheckoutParams) => {
81
+ const uid = normalizeUserId(userId);
82
+ const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
83
+ if (!result.url.startsWith('https://')) {
84
+ throw new Error('[polar-billing] Invalid checkout URL returned: URL must start with https://');
85
+ }
86
+ window.location.href = result.url;
87
+ }, [userId]);
88
+
89
+ const syncSubscription = useCallback(async (): Promise<SyncResult> => {
90
+ const uid = normalizeUserId(userId);
91
+ if (!uid) return { synced: false };
92
+
93
+ const checkoutId = new URLSearchParams(window.location.search).get('checkout_id') ?? undefined;
94
+ const result = await adapterRef.current.syncSubscription(uid, checkoutId);
95
+ if (result.synced) await refresh();
96
+ return result;
97
+ }, [userId, refresh]);
98
+
99
+ const getBillingHistory = useCallback(async (): Promise<OrderItem[]> => {
100
+ const uid = normalizeUserId(userId);
101
+ if (!uid) return [];
102
+ return adapterRef.current.getBillingHistory(uid);
103
+ }, [userId]);
104
+
105
+ const cancelSubscription = useCallback(
106
+ async (reason?: CancellationReason): Promise<CancelResult> => {
107
+ const result = await adapterRef.current.cancelSubscription(reason);
108
+ if (result.success) await refresh();
109
+ return result;
110
+ },
111
+ [refresh],
112
+ );
113
+
114
+ const getPortalUrl = useCallback(async (): Promise<string> => {
115
+ const uid = normalizeUserId(userId);
116
+ if (!uid) throw new Error('[polar-billing] Cannot get portal URL: No authenticated user');
117
+ return adapterRef.current.getPortalUrl(uid);
118
+ }, [userId]);
119
+
120
+ const value = useMemo(
121
+ () => ({
122
+ status,
123
+ loading,
124
+ refresh,
125
+ startCheckout,
126
+ syncSubscription,
127
+ getBillingHistory,
128
+ cancelSubscription,
129
+ getPortalUrl,
130
+ }),
131
+ [status, loading, refresh, startCheckout, syncSubscription, getBillingHistory, cancelSubscription, getPortalUrl],
132
+ );
133
+
134
+ return <PolarContext.Provider value={value}>{children}</PolarContext.Provider>;
135
+ }
@@ -0,0 +1,34 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type {
3
+ SubscriptionStatus,
4
+ CheckoutParams,
5
+ CancellationReason,
6
+ CancelResult,
7
+ SyncResult,
8
+ OrderItem,
9
+ } from '../../domain/entities';
10
+
11
+ export interface PolarContextValue {
12
+ status: SubscriptionStatus;
13
+ loading: boolean;
14
+ refresh: () => Promise<void>;
15
+ startCheckout: (params: CheckoutParams) => Promise<void>;
16
+ syncSubscription: () => Promise<SyncResult>;
17
+ getBillingHistory: () => Promise<OrderItem[]>;
18
+ cancelSubscription: (reason?: CancellationReason) => Promise<CancelResult>;
19
+ getPortalUrl: () => Promise<string>;
20
+ }
21
+
22
+ export const PolarContext = createContext<PolarContextValue | undefined>(undefined);
23
+
24
+ /**
25
+ * usePolarBilling Hook
26
+ * @description Hook to access Polar billing context
27
+ */
28
+ export function usePolarBilling(): PolarContextValue {
29
+ const ctx = useContext(PolarContext);
30
+ if (!ctx) throw new Error('usePolarBilling must be used within <PolarProvider>');
31
+ return ctx;
32
+ }
33
+
34
+ export const useSubscription = usePolarBilling;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Presentation Layer
3
+ * Subpath: @umituz/web-polar-payment/presentation
4
+ */
5
+
6
+ export * from './components/PolarProvider';
7
+ export * from './hooks/usePolarBilling';