@umituz/web-polar-payment 1.0.12 → 1.0.14
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 +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +59 -36
- package/dist/index.mjs +59 -36
- package/package.json +1 -1
- package/src/infrastructure/constants/billing.constants.ts +13 -2
- package/src/infrastructure/services/firebase-billing.service.ts +23 -2
- package/src/infrastructure/utils/normalization.util.ts +16 -14
- package/src/presentation/components/PolarProvider.tsx +28 -15
package/dist/index.d.mts
CHANGED
|
@@ -118,7 +118,7 @@ declare function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdap
|
|
|
118
118
|
* Billing Constants
|
|
119
119
|
* @description Standardized subscription states and plan names
|
|
120
120
|
*/
|
|
121
|
-
declare const SUBSCRIPTION_STATUS: {
|
|
121
|
+
declare const SUBSCRIPTION_STATUS: Readonly<{
|
|
122
122
|
ACTIVE: "active";
|
|
123
123
|
CANCELED: "canceled";
|
|
124
124
|
REVOKED: "revoked";
|
|
@@ -128,7 +128,7 @@ declare const SUBSCRIPTION_STATUS: {
|
|
|
128
128
|
INCOMPLETE_EXPIRED: "incomplete_expired";
|
|
129
129
|
UNPAID: "unpaid";
|
|
130
130
|
NONE: "none";
|
|
131
|
-
}
|
|
131
|
+
}>;
|
|
132
132
|
declare const FREE_PLAN = "free";
|
|
133
133
|
|
|
134
134
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -118,7 +118,7 @@ declare function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdap
|
|
|
118
118
|
* Billing Constants
|
|
119
119
|
* @description Standardized subscription states and plan names
|
|
120
120
|
*/
|
|
121
|
-
declare const SUBSCRIPTION_STATUS: {
|
|
121
|
+
declare const SUBSCRIPTION_STATUS: Readonly<{
|
|
122
122
|
ACTIVE: "active";
|
|
123
123
|
CANCELED: "canceled";
|
|
124
124
|
REVOKED: "revoked";
|
|
@@ -128,7 +128,7 @@ declare const SUBSCRIPTION_STATUS: {
|
|
|
128
128
|
INCOMPLETE_EXPIRED: "incomplete_expired";
|
|
129
129
|
UNPAID: "unpaid";
|
|
130
130
|
NONE: "none";
|
|
131
|
-
}
|
|
131
|
+
}>;
|
|
132
132
|
declare const FREE_PLAN = "free";
|
|
133
133
|
|
|
134
134
|
/**
|
package/dist/index.js
CHANGED
|
@@ -43,21 +43,21 @@ __export(index_exports, {
|
|
|
43
43
|
module.exports = __toCommonJS(index_exports);
|
|
44
44
|
|
|
45
45
|
// src/infrastructure/utils/normalization.util.ts
|
|
46
|
+
var STATUS_MAP = {
|
|
47
|
+
active: "active",
|
|
48
|
+
trialing: "trialing",
|
|
49
|
+
past_due: "past_due",
|
|
50
|
+
incomplete: "incomplete",
|
|
51
|
+
incomplete_expired: "incomplete_expired",
|
|
52
|
+
unpaid: "unpaid",
|
|
53
|
+
canceled: "canceled",
|
|
54
|
+
cancelled: "canceled",
|
|
55
|
+
revoked: "revoked",
|
|
56
|
+
none: "none"
|
|
57
|
+
};
|
|
46
58
|
function normalizeStatus(raw) {
|
|
47
|
-
const map = {
|
|
48
|
-
active: "active",
|
|
49
|
-
trialing: "trialing",
|
|
50
|
-
past_due: "past_due",
|
|
51
|
-
incomplete: "incomplete",
|
|
52
|
-
incomplete_expired: "incomplete_expired",
|
|
53
|
-
unpaid: "unpaid",
|
|
54
|
-
canceled: "canceled",
|
|
55
|
-
cancelled: "canceled",
|
|
56
|
-
revoked: "revoked",
|
|
57
|
-
none: "none"
|
|
58
|
-
};
|
|
59
59
|
if (typeof raw !== "string") return "none";
|
|
60
|
-
return
|
|
60
|
+
return STATUS_MAP[raw.toLowerCase()] ?? "none";
|
|
61
61
|
}
|
|
62
62
|
function normalizeBillingCycle(interval) {
|
|
63
63
|
if (typeof interval !== "string") return "monthly";
|
|
@@ -99,15 +99,31 @@ function createFirebaseAdapter(config) {
|
|
|
99
99
|
cancelAtPeriodEnd: config.db?.cancelAtPeriodEndField ?? "cancelAtPeriodEnd",
|
|
100
100
|
currentPeriodEnd: config.db?.currentPeriodEndField ?? "currentPeriodEnd"
|
|
101
101
|
};
|
|
102
|
+
let httpsCallableCache = null;
|
|
103
|
+
let firestoreCache = null;
|
|
104
|
+
async function getHttpsCallable() {
|
|
105
|
+
if (!httpsCallableCache) {
|
|
106
|
+
const mod = await import("firebase/functions");
|
|
107
|
+
httpsCallableCache = mod.httpsCallable;
|
|
108
|
+
}
|
|
109
|
+
return httpsCallableCache;
|
|
110
|
+
}
|
|
111
|
+
async function getFirestore() {
|
|
112
|
+
if (!firestoreCache) {
|
|
113
|
+
const mod = await import("firebase/firestore");
|
|
114
|
+
firestoreCache = { doc: mod.doc, getDoc: mod.getDoc };
|
|
115
|
+
}
|
|
116
|
+
return firestoreCache;
|
|
117
|
+
}
|
|
102
118
|
async function callable(name, data) {
|
|
103
|
-
const
|
|
119
|
+
const httpsCallable = await getHttpsCallable();
|
|
104
120
|
const fn = httpsCallable(functions, name);
|
|
105
121
|
const result = await fn(data);
|
|
106
122
|
return result.data;
|
|
107
123
|
}
|
|
108
124
|
return {
|
|
109
125
|
async getStatus(userId) {
|
|
110
|
-
const { doc, getDoc } = await
|
|
126
|
+
const { doc, getDoc } = await getFirestore();
|
|
111
127
|
const snap = await getDoc(doc(firestore, db.collection, userId));
|
|
112
128
|
if (!snap.exists()) {
|
|
113
129
|
return { plan: "free", subscriptionStatus: "none" };
|
|
@@ -161,7 +177,7 @@ function createFirebaseAdapter(config) {
|
|
|
161
177
|
}
|
|
162
178
|
|
|
163
179
|
// src/infrastructure/constants/billing.constants.ts
|
|
164
|
-
var SUBSCRIPTION_STATUS = {
|
|
180
|
+
var SUBSCRIPTION_STATUS = Object.freeze({
|
|
165
181
|
ACTIVE: "active",
|
|
166
182
|
CANCELED: "canceled",
|
|
167
183
|
REVOKED: "revoked",
|
|
@@ -171,7 +187,7 @@ var SUBSCRIPTION_STATUS = {
|
|
|
171
187
|
INCOMPLETE_EXPIRED: "incomplete_expired",
|
|
172
188
|
UNPAID: "unpaid",
|
|
173
189
|
NONE: "none"
|
|
174
|
-
};
|
|
190
|
+
});
|
|
175
191
|
var FREE_PLAN = "free";
|
|
176
192
|
|
|
177
193
|
// src/presentation/components/PolarProvider.tsx
|
|
@@ -202,31 +218,36 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
202
218
|
const [status, setStatus] = (0, import_react2.useState)(FREE_STATUS);
|
|
203
219
|
const [loading, setLoading] = (0, import_react2.useState)(true);
|
|
204
220
|
const adapterRef = (0, import_react2.useRef)(adapter);
|
|
205
|
-
|
|
221
|
+
const statusRef = (0, import_react2.useRef)({ setStatus, setLoading });
|
|
222
|
+
const userIdRef = (0, import_react2.useRef)(normalizeUserId(userId));
|
|
206
223
|
const refreshAbortRef = (0, import_react2.useRef)(null);
|
|
224
|
+
adapterRef.current = adapter;
|
|
225
|
+
statusRef.current = { setStatus, setLoading };
|
|
226
|
+
userIdRef.current = normalizeUserId(userId);
|
|
207
227
|
const refresh = (0, import_react2.useCallback)(async () => {
|
|
208
|
-
const uid =
|
|
228
|
+
const uid = userIdRef.current;
|
|
229
|
+
const { setStatus: setStatus2, setLoading: setLoading2 } = statusRef.current;
|
|
209
230
|
if (!uid) {
|
|
210
|
-
|
|
211
|
-
|
|
231
|
+
setStatus2(FREE_STATUS);
|
|
232
|
+
setLoading2(false);
|
|
212
233
|
return;
|
|
213
234
|
}
|
|
214
235
|
refreshAbortRef.current?.abort();
|
|
215
236
|
const ctrl = new AbortController();
|
|
216
237
|
refreshAbortRef.current = ctrl;
|
|
217
238
|
try {
|
|
218
|
-
|
|
239
|
+
setLoading2(true);
|
|
219
240
|
const s = await adapterRef.current.getStatus(uid);
|
|
220
|
-
if (!ctrl.signal.aborted)
|
|
241
|
+
if (!ctrl.signal.aborted) setStatus2(s);
|
|
221
242
|
} catch (err) {
|
|
222
243
|
if (!ctrl.signal.aborted) {
|
|
223
244
|
console.error("[polar-billing] getStatus failed:", err);
|
|
224
|
-
|
|
245
|
+
setStatus2(FREE_STATUS);
|
|
225
246
|
}
|
|
226
247
|
} finally {
|
|
227
|
-
if (!ctrl.signal.aborted)
|
|
248
|
+
if (!ctrl.signal.aborted) setLoading2(false);
|
|
228
249
|
}
|
|
229
|
-
}, [
|
|
250
|
+
}, []);
|
|
230
251
|
(0, import_react2.useEffect)(() => {
|
|
231
252
|
refresh();
|
|
232
253
|
return () => {
|
|
@@ -234,26 +255,26 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
234
255
|
};
|
|
235
256
|
}, [refresh]);
|
|
236
257
|
const startCheckout = (0, import_react2.useCallback)(async (params) => {
|
|
237
|
-
const uid =
|
|
238
|
-
const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
|
|
258
|
+
const uid = userIdRef.current;
|
|
259
|
+
const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? void 0 });
|
|
239
260
|
if (!result.url.startsWith("https://")) {
|
|
240
261
|
throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https://");
|
|
241
262
|
}
|
|
242
263
|
window.location.href = result.url;
|
|
243
|
-
}, [
|
|
264
|
+
}, []);
|
|
244
265
|
const syncSubscription = (0, import_react2.useCallback)(async () => {
|
|
245
|
-
const uid =
|
|
266
|
+
const uid = userIdRef.current;
|
|
246
267
|
if (!uid) return { synced: false };
|
|
247
268
|
const checkoutId = new URLSearchParams(window.location.search).get("checkout_id") ?? void 0;
|
|
248
269
|
const result = await adapterRef.current.syncSubscription(uid, checkoutId);
|
|
249
270
|
if (result.synced) await refresh();
|
|
250
271
|
return result;
|
|
251
|
-
}, [
|
|
272
|
+
}, [refresh]);
|
|
252
273
|
const getBillingHistory = (0, import_react2.useCallback)(async () => {
|
|
253
|
-
const uid =
|
|
274
|
+
const uid = userIdRef.current;
|
|
254
275
|
if (!uid) return [];
|
|
255
276
|
return adapterRef.current.getBillingHistory(uid);
|
|
256
|
-
}, [
|
|
277
|
+
}, []);
|
|
257
278
|
const cancelSubscription = (0, import_react2.useCallback)(
|
|
258
279
|
async (reason) => {
|
|
259
280
|
const result = await adapterRef.current.cancelSubscription(reason);
|
|
@@ -261,12 +282,13 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
261
282
|
return result;
|
|
262
283
|
},
|
|
263
284
|
[refresh]
|
|
285
|
+
// Only depends on stable refresh
|
|
264
286
|
);
|
|
265
287
|
const getPortalUrl = (0, import_react2.useCallback)(async () => {
|
|
266
|
-
const uid =
|
|
288
|
+
const uid = userIdRef.current;
|
|
267
289
|
if (!uid) throw new Error("[polar-billing] Cannot get portal URL: No authenticated user");
|
|
268
290
|
return adapterRef.current.getPortalUrl(uid);
|
|
269
|
-
}, [
|
|
291
|
+
}, []);
|
|
270
292
|
const value = (0, import_react2.useMemo)(
|
|
271
293
|
() => ({
|
|
272
294
|
status,
|
|
@@ -278,7 +300,8 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
278
300
|
cancelSubscription,
|
|
279
301
|
getPortalUrl
|
|
280
302
|
}),
|
|
281
|
-
[status, loading, refresh,
|
|
303
|
+
[status, loading, refresh, syncSubscription]
|
|
304
|
+
// startCheckout, getBillingHistory, cancelSubscription, getPortalUrl are stable
|
|
282
305
|
);
|
|
283
306
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PolarContext.Provider, { value, children });
|
|
284
307
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
// src/infrastructure/utils/normalization.util.ts
|
|
2
|
+
var STATUS_MAP = {
|
|
3
|
+
active: "active",
|
|
4
|
+
trialing: "trialing",
|
|
5
|
+
past_due: "past_due",
|
|
6
|
+
incomplete: "incomplete",
|
|
7
|
+
incomplete_expired: "incomplete_expired",
|
|
8
|
+
unpaid: "unpaid",
|
|
9
|
+
canceled: "canceled",
|
|
10
|
+
cancelled: "canceled",
|
|
11
|
+
revoked: "revoked",
|
|
12
|
+
none: "none"
|
|
13
|
+
};
|
|
2
14
|
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
15
|
if (typeof raw !== "string") return "none";
|
|
16
|
-
return
|
|
16
|
+
return STATUS_MAP[raw.toLowerCase()] ?? "none";
|
|
17
17
|
}
|
|
18
18
|
function normalizeBillingCycle(interval) {
|
|
19
19
|
if (typeof interval !== "string") return "monthly";
|
|
@@ -55,15 +55,31 @@ function createFirebaseAdapter(config) {
|
|
|
55
55
|
cancelAtPeriodEnd: config.db?.cancelAtPeriodEndField ?? "cancelAtPeriodEnd",
|
|
56
56
|
currentPeriodEnd: config.db?.currentPeriodEndField ?? "currentPeriodEnd"
|
|
57
57
|
};
|
|
58
|
+
let httpsCallableCache = null;
|
|
59
|
+
let firestoreCache = null;
|
|
60
|
+
async function getHttpsCallable() {
|
|
61
|
+
if (!httpsCallableCache) {
|
|
62
|
+
const mod = await import("firebase/functions");
|
|
63
|
+
httpsCallableCache = mod.httpsCallable;
|
|
64
|
+
}
|
|
65
|
+
return httpsCallableCache;
|
|
66
|
+
}
|
|
67
|
+
async function getFirestore() {
|
|
68
|
+
if (!firestoreCache) {
|
|
69
|
+
const mod = await import("firebase/firestore");
|
|
70
|
+
firestoreCache = { doc: mod.doc, getDoc: mod.getDoc };
|
|
71
|
+
}
|
|
72
|
+
return firestoreCache;
|
|
73
|
+
}
|
|
58
74
|
async function callable(name, data) {
|
|
59
|
-
const
|
|
75
|
+
const httpsCallable = await getHttpsCallable();
|
|
60
76
|
const fn = httpsCallable(functions, name);
|
|
61
77
|
const result = await fn(data);
|
|
62
78
|
return result.data;
|
|
63
79
|
}
|
|
64
80
|
return {
|
|
65
81
|
async getStatus(userId) {
|
|
66
|
-
const { doc, getDoc } = await
|
|
82
|
+
const { doc, getDoc } = await getFirestore();
|
|
67
83
|
const snap = await getDoc(doc(firestore, db.collection, userId));
|
|
68
84
|
if (!snap.exists()) {
|
|
69
85
|
return { plan: "free", subscriptionStatus: "none" };
|
|
@@ -117,7 +133,7 @@ function createFirebaseAdapter(config) {
|
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
// src/infrastructure/constants/billing.constants.ts
|
|
120
|
-
var SUBSCRIPTION_STATUS = {
|
|
136
|
+
var SUBSCRIPTION_STATUS = Object.freeze({
|
|
121
137
|
ACTIVE: "active",
|
|
122
138
|
CANCELED: "canceled",
|
|
123
139
|
REVOKED: "revoked",
|
|
@@ -127,7 +143,7 @@ var SUBSCRIPTION_STATUS = {
|
|
|
127
143
|
INCOMPLETE_EXPIRED: "incomplete_expired",
|
|
128
144
|
UNPAID: "unpaid",
|
|
129
145
|
NONE: "none"
|
|
130
|
-
};
|
|
146
|
+
});
|
|
131
147
|
var FREE_PLAN = "free";
|
|
132
148
|
|
|
133
149
|
// src/presentation/components/PolarProvider.tsx
|
|
@@ -164,31 +180,36 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
164
180
|
const [status, setStatus] = useState(FREE_STATUS);
|
|
165
181
|
const [loading, setLoading] = useState(true);
|
|
166
182
|
const adapterRef = useRef(adapter);
|
|
167
|
-
|
|
183
|
+
const statusRef = useRef({ setStatus, setLoading });
|
|
184
|
+
const userIdRef = useRef(normalizeUserId(userId));
|
|
168
185
|
const refreshAbortRef = useRef(null);
|
|
186
|
+
adapterRef.current = adapter;
|
|
187
|
+
statusRef.current = { setStatus, setLoading };
|
|
188
|
+
userIdRef.current = normalizeUserId(userId);
|
|
169
189
|
const refresh = useCallback(async () => {
|
|
170
|
-
const uid =
|
|
190
|
+
const uid = userIdRef.current;
|
|
191
|
+
const { setStatus: setStatus2, setLoading: setLoading2 } = statusRef.current;
|
|
171
192
|
if (!uid) {
|
|
172
|
-
|
|
173
|
-
|
|
193
|
+
setStatus2(FREE_STATUS);
|
|
194
|
+
setLoading2(false);
|
|
174
195
|
return;
|
|
175
196
|
}
|
|
176
197
|
refreshAbortRef.current?.abort();
|
|
177
198
|
const ctrl = new AbortController();
|
|
178
199
|
refreshAbortRef.current = ctrl;
|
|
179
200
|
try {
|
|
180
|
-
|
|
201
|
+
setLoading2(true);
|
|
181
202
|
const s = await adapterRef.current.getStatus(uid);
|
|
182
|
-
if (!ctrl.signal.aborted)
|
|
203
|
+
if (!ctrl.signal.aborted) setStatus2(s);
|
|
183
204
|
} catch (err) {
|
|
184
205
|
if (!ctrl.signal.aborted) {
|
|
185
206
|
console.error("[polar-billing] getStatus failed:", err);
|
|
186
|
-
|
|
207
|
+
setStatus2(FREE_STATUS);
|
|
187
208
|
}
|
|
188
209
|
} finally {
|
|
189
|
-
if (!ctrl.signal.aborted)
|
|
210
|
+
if (!ctrl.signal.aborted) setLoading2(false);
|
|
190
211
|
}
|
|
191
|
-
}, [
|
|
212
|
+
}, []);
|
|
192
213
|
useEffect(() => {
|
|
193
214
|
refresh();
|
|
194
215
|
return () => {
|
|
@@ -196,26 +217,26 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
196
217
|
};
|
|
197
218
|
}, [refresh]);
|
|
198
219
|
const startCheckout = useCallback(async (params) => {
|
|
199
|
-
const uid =
|
|
200
|
-
const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
|
|
220
|
+
const uid = userIdRef.current;
|
|
221
|
+
const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? void 0 });
|
|
201
222
|
if (!result.url.startsWith("https://")) {
|
|
202
223
|
throw new Error("[polar-billing] Invalid checkout URL returned: URL must start with https://");
|
|
203
224
|
}
|
|
204
225
|
window.location.href = result.url;
|
|
205
|
-
}, [
|
|
226
|
+
}, []);
|
|
206
227
|
const syncSubscription = useCallback(async () => {
|
|
207
|
-
const uid =
|
|
228
|
+
const uid = userIdRef.current;
|
|
208
229
|
if (!uid) return { synced: false };
|
|
209
230
|
const checkoutId = new URLSearchParams(window.location.search).get("checkout_id") ?? void 0;
|
|
210
231
|
const result = await adapterRef.current.syncSubscription(uid, checkoutId);
|
|
211
232
|
if (result.synced) await refresh();
|
|
212
233
|
return result;
|
|
213
|
-
}, [
|
|
234
|
+
}, [refresh]);
|
|
214
235
|
const getBillingHistory = useCallback(async () => {
|
|
215
|
-
const uid =
|
|
236
|
+
const uid = userIdRef.current;
|
|
216
237
|
if (!uid) return [];
|
|
217
238
|
return adapterRef.current.getBillingHistory(uid);
|
|
218
|
-
}, [
|
|
239
|
+
}, []);
|
|
219
240
|
const cancelSubscription = useCallback(
|
|
220
241
|
async (reason) => {
|
|
221
242
|
const result = await adapterRef.current.cancelSubscription(reason);
|
|
@@ -223,12 +244,13 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
223
244
|
return result;
|
|
224
245
|
},
|
|
225
246
|
[refresh]
|
|
247
|
+
// Only depends on stable refresh
|
|
226
248
|
);
|
|
227
249
|
const getPortalUrl = useCallback(async () => {
|
|
228
|
-
const uid =
|
|
250
|
+
const uid = userIdRef.current;
|
|
229
251
|
if (!uid) throw new Error("[polar-billing] Cannot get portal URL: No authenticated user");
|
|
230
252
|
return adapterRef.current.getPortalUrl(uid);
|
|
231
|
-
}, [
|
|
253
|
+
}, []);
|
|
232
254
|
const value = useMemo(
|
|
233
255
|
() => ({
|
|
234
256
|
status,
|
|
@@ -240,7 +262,8 @@ function PolarProvider({ adapter, userId, children }) {
|
|
|
240
262
|
cancelSubscription,
|
|
241
263
|
getPortalUrl
|
|
242
264
|
}),
|
|
243
|
-
[status, loading, refresh,
|
|
265
|
+
[status, loading, refresh, syncSubscription]
|
|
266
|
+
// startCheckout, getBillingHistory, cancelSubscription, getPortalUrl are stable
|
|
244
267
|
);
|
|
245
268
|
return /* @__PURE__ */ jsx(PolarContext.Provider, { value, children });
|
|
246
269
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* @description Standardized subscription states and plan names
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Object.freeze() prevents accidental mutations and enables V8 optimizations
|
|
7
|
+
export const SUBSCRIPTION_STATUS = Object.freeze({
|
|
7
8
|
ACTIVE: 'active' as const,
|
|
8
9
|
CANCELED: 'canceled' as const,
|
|
9
10
|
REVOKED: 'revoked' as const,
|
|
@@ -13,6 +14,16 @@ export const SUBSCRIPTION_STATUS = {
|
|
|
13
14
|
INCOMPLETE_EXPIRED: 'incomplete_expired' as const,
|
|
14
15
|
UNPAID: 'unpaid' as const,
|
|
15
16
|
NONE: 'none' as const,
|
|
16
|
-
}
|
|
17
|
+
}) as Readonly<{
|
|
18
|
+
ACTIVE: 'active';
|
|
19
|
+
CANCELED: 'canceled';
|
|
20
|
+
REVOKED: 'revoked';
|
|
21
|
+
TRIALING: 'trialing';
|
|
22
|
+
PAST_DUE: 'past_due';
|
|
23
|
+
INCOMPLETE: 'incomplete';
|
|
24
|
+
INCOMPLETE_EXPIRED: 'incomplete_expired';
|
|
25
|
+
UNPAID: 'unpaid';
|
|
26
|
+
NONE: 'none';
|
|
27
|
+
}>;
|
|
17
28
|
|
|
18
29
|
export const FREE_PLAN = 'free';
|
|
@@ -95,8 +95,29 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
|
|
|
95
95
|
currentPeriodEnd: config.db?.currentPeriodEndField ?? 'currentPeriodEnd',
|
|
96
96
|
};
|
|
97
97
|
|
|
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;
|
|
102
|
+
|
|
103
|
+
async function getHttpsCallable() {
|
|
104
|
+
if (!httpsCallableCache) {
|
|
105
|
+
const mod = await import('firebase/functions');
|
|
106
|
+
httpsCallableCache = mod.httpsCallable;
|
|
107
|
+
}
|
|
108
|
+
return httpsCallableCache;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function getFirestore() {
|
|
112
|
+
if (!firestoreCache) {
|
|
113
|
+
const mod = await import('firebase/firestore');
|
|
114
|
+
firestoreCache = { doc: mod.doc, getDoc: mod.getDoc };
|
|
115
|
+
}
|
|
116
|
+
return firestoreCache;
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
async function callable<T = unknown, R = unknown>(name: string, data?: T): Promise<R> {
|
|
99
|
-
const
|
|
120
|
+
const httpsCallable = await getHttpsCallable();
|
|
100
121
|
const fn = (httpsCallable as any)(functions, name) as (data?: T) => Promise<{ data: R }>;
|
|
101
122
|
const result = await fn(data);
|
|
102
123
|
return result.data;
|
|
@@ -104,7 +125,7 @@ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapt
|
|
|
104
125
|
|
|
105
126
|
return {
|
|
106
127
|
async getStatus(userId: string): Promise<SubscriptionStatus> {
|
|
107
|
-
const { doc, getDoc } = await
|
|
128
|
+
const { doc, getDoc } = await getFirestore();
|
|
108
129
|
const snap = await getDoc((doc as any)(firestore, db.collection, userId));
|
|
109
130
|
|
|
110
131
|
if (!snap.exists()) {
|
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import type { SubscriptionStatusValue, BillingCycle } from '../../domain/entities';
|
|
2
2
|
|
|
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
|
+
};
|
|
17
|
+
|
|
3
18
|
/**
|
|
4
19
|
* Normalize a raw Polar status string to a known value.
|
|
5
20
|
* @description Defaults to 'none' for unknown statuses or non-string input.
|
|
6
21
|
*/
|
|
7
22
|
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
23
|
if (typeof raw !== 'string') return 'none';
|
|
22
|
-
return
|
|
24
|
+
return STATUS_MAP[raw.toLowerCase()] ?? 'none';
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -42,12 +42,23 @@ function normalizeUserId(userId: string | undefined): string | undefined {
|
|
|
42
42
|
export function PolarProvider({ adapter, userId, children }: PolarProviderProps) {
|
|
43
43
|
const [status, setStatus] = useState<SubscriptionStatus>(FREE_STATUS);
|
|
44
44
|
const [loading, setLoading] = useState(true);
|
|
45
|
+
|
|
46
|
+
// Store adapter and setters in refs to create stable callbacks
|
|
45
47
|
const adapterRef = useRef(adapter);
|
|
46
|
-
|
|
48
|
+
const statusRef = useRef({ setStatus, setLoading });
|
|
49
|
+
const userIdRef = useRef(normalizeUserId(userId));
|
|
47
50
|
const refreshAbortRef = useRef<AbortController | null>(null);
|
|
48
51
|
|
|
52
|
+
// Keep refs in sync
|
|
53
|
+
adapterRef.current = adapter;
|
|
54
|
+
statusRef.current = { setStatus, setLoading };
|
|
55
|
+
userIdRef.current = normalizeUserId(userId);
|
|
56
|
+
|
|
57
|
+
// Stable refresh function with no dependencies - prevents cascading re-renders
|
|
49
58
|
const refresh = useCallback(async () => {
|
|
50
|
-
const uid =
|
|
59
|
+
const uid = userIdRef.current;
|
|
60
|
+
const { setStatus, setLoading } = statusRef.current;
|
|
61
|
+
|
|
51
62
|
if (!uid) {
|
|
52
63
|
setStatus(FREE_STATUS);
|
|
53
64
|
setLoading(false);
|
|
@@ -70,37 +81,37 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
|
|
|
70
81
|
} finally {
|
|
71
82
|
if (!ctrl.signal.aborted) setLoading(false);
|
|
72
83
|
}
|
|
73
|
-
}, [
|
|
84
|
+
}, []); // No dependencies - completely stable
|
|
74
85
|
|
|
75
86
|
useEffect(() => {
|
|
76
87
|
refresh();
|
|
77
88
|
return () => { refreshAbortRef.current?.abort(); };
|
|
78
|
-
}, [refresh]);
|
|
89
|
+
}, [refresh]); // Only re-run if refresh identity changes (never)
|
|
79
90
|
|
|
80
91
|
const startCheckout = useCallback(async (params: CheckoutParams) => {
|
|
81
|
-
const uid =
|
|
82
|
-
const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
|
|
92
|
+
const uid = userIdRef.current;
|
|
93
|
+
const result = await adapterRef.current.createCheckout({ ...params, userId: uid ?? undefined });
|
|
83
94
|
if (!result.url.startsWith('https://')) {
|
|
84
95
|
throw new Error('[polar-billing] Invalid checkout URL returned: URL must start with https://');
|
|
85
96
|
}
|
|
86
97
|
window.location.href = result.url;
|
|
87
|
-
}, [
|
|
98
|
+
}, []); // No dependencies - stable
|
|
88
99
|
|
|
89
100
|
const syncSubscription = useCallback(async (): Promise<SyncResult> => {
|
|
90
|
-
const uid =
|
|
101
|
+
const uid = userIdRef.current;
|
|
91
102
|
if (!uid) return { synced: false };
|
|
92
103
|
|
|
93
104
|
const checkoutId = new URLSearchParams(window.location.search).get('checkout_id') ?? undefined;
|
|
94
105
|
const result = await adapterRef.current.syncSubscription(uid, checkoutId);
|
|
95
106
|
if (result.synced) await refresh();
|
|
96
107
|
return result;
|
|
97
|
-
}, [
|
|
108
|
+
}, [refresh]); // Only depends on stable refresh
|
|
98
109
|
|
|
99
110
|
const getBillingHistory = useCallback(async (): Promise<OrderItem[]> => {
|
|
100
|
-
const uid =
|
|
111
|
+
const uid = userIdRef.current;
|
|
101
112
|
if (!uid) return [];
|
|
102
113
|
return adapterRef.current.getBillingHistory(uid);
|
|
103
|
-
}, [
|
|
114
|
+
}, []); // No dependencies - stable
|
|
104
115
|
|
|
105
116
|
const cancelSubscription = useCallback(
|
|
106
117
|
async (reason?: CancellationReason): Promise<CancelResult> => {
|
|
@@ -108,15 +119,17 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
|
|
|
108
119
|
if (result.success) await refresh();
|
|
109
120
|
return result;
|
|
110
121
|
},
|
|
111
|
-
[refresh],
|
|
122
|
+
[refresh], // Only depends on stable refresh
|
|
112
123
|
);
|
|
113
124
|
|
|
114
125
|
const getPortalUrl = useCallback(async (): Promise<string> => {
|
|
115
|
-
const uid =
|
|
126
|
+
const uid = userIdRef.current;
|
|
116
127
|
if (!uid) throw new Error('[polar-billing] Cannot get portal URL: No authenticated user');
|
|
117
128
|
return adapterRef.current.getPortalUrl(uid);
|
|
118
|
-
}, [
|
|
129
|
+
}, []); // No dependencies - stable
|
|
119
130
|
|
|
131
|
+
// Memoized context value - only recreates when status/loading changes
|
|
132
|
+
// All functions are stable, so they don't trigger re-creation
|
|
120
133
|
const value = useMemo(
|
|
121
134
|
() => ({
|
|
122
135
|
status,
|
|
@@ -128,7 +141,7 @@ export function PolarProvider({ adapter, userId, children }: PolarProviderProps)
|
|
|
128
141
|
cancelSubscription,
|
|
129
142
|
getPortalUrl,
|
|
130
143
|
}),
|
|
131
|
-
[status, loading, refresh,
|
|
144
|
+
[status, loading, refresh, syncSubscription], // startCheckout, getBillingHistory, cancelSubscription, getPortalUrl are stable
|
|
132
145
|
);
|
|
133
146
|
|
|
134
147
|
return <PolarContext.Provider value={value}>{children}</PolarContext.Provider>;
|