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