@umituz/web-polar-payment 1.0.4 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-polar-payment",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Universal Polar.sh subscription billing — Supabase & Firebase adapters",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -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,132 @@
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
+ // We use any for firebase types in implementation to bypass DTS build issues with external packages
14
+ type FirebaseFunctions = any;
15
+ type FirebaseFirestore = any;
16
+
17
+ export interface FirebaseAdapterConfig {
18
+ functions: FirebaseFunctions;
19
+ firestore: FirebaseFirestore;
20
+ callables?: {
21
+ createCheckout?: string;
22
+ syncSubscription?: string;
23
+ getBillingHistory?: string;
24
+ cancelSubscription?: string;
25
+ getPortalUrl?: string;
26
+ };
27
+ db?: {
28
+ usersCollection?: string;
29
+ planField?: string;
30
+ billingCycleField?: string;
31
+ subscriptionIdField?: string;
32
+ subscriptionStatusField?: string;
33
+ polarCustomerIdField?: string;
34
+ cancelAtPeriodEndField?: string;
35
+ currentPeriodEndField?: string;
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Firebase Billing Service
41
+ * @description Implementation of PolarAdapter for Firebase Functions and Firestore.
42
+ */
43
+ export function createFirebaseAdapter(config: FirebaseAdapterConfig): PolarAdapter {
44
+ const callables = {
45
+ createCheckout: config.callables?.createCheckout ?? 'createCheckoutSession',
46
+ sync: config.callables?.syncSubscription ?? 'syncSubscription',
47
+ billing: config.callables?.getBillingHistory ?? 'getBillingHistory',
48
+ cancel: config.callables?.cancelSubscription ?? 'cancelSubscription',
49
+ portal: config.callables?.getPortalUrl ?? 'getCustomerPortalUrl',
50
+ };
51
+
52
+ const db = {
53
+ collection: config.db?.usersCollection ?? 'users',
54
+ plan: config.db?.planField ?? 'plan',
55
+ billingCycle: config.db?.billingCycleField ?? 'billingCycle',
56
+ subscriptionId: config.db?.subscriptionIdField ?? 'subscriptionId',
57
+ subscriptionStatus: config.db?.subscriptionStatusField ?? 'subscriptionStatus',
58
+ polarCustomerId: config.db?.polarCustomerIdField ?? 'polarCustomerId',
59
+ cancelAtPeriodEnd: config.db?.cancelAtPeriodEndField ?? 'cancelAtPeriodEnd',
60
+ currentPeriodEnd: config.db?.currentPeriodEndField ?? 'currentPeriodEnd',
61
+ };
62
+
63
+ async function callable<T = unknown, R = unknown>(name: string, data?: T): Promise<R> {
64
+ const { httpsCallable } = await import('firebase/functions');
65
+ const fn = (httpsCallable as any)(config.functions, name) as (data?: T) => Promise<{ data: R }>;
66
+ const result = await fn(data);
67
+ return result.data;
68
+ }
69
+
70
+ return {
71
+ async getStatus(userId: string): Promise<SubscriptionStatus> {
72
+ const { doc, getDoc } = await import('firebase/firestore');
73
+ const snap = await getDoc((doc as any)(config.firestore, db.collection, userId));
74
+
75
+ if (!snap.exists()) {
76
+ return { plan: 'free', subscriptionStatus: 'none' };
77
+ }
78
+
79
+ const d = snap.data() as Record<string, unknown>;
80
+
81
+ let currentPeriodEnd: string | undefined;
82
+ const rawEnd = d[db.currentPeriodEnd];
83
+ if (rawEnd != null) {
84
+ if (typeof rawEnd === 'object' && 'toDate' in (rawEnd as object)) {
85
+ currentPeriodEnd = (rawEnd as { toDate(): Date }).toDate().toISOString();
86
+ } else if (typeof rawEnd === 'string') {
87
+ currentPeriodEnd = rawEnd;
88
+ }
89
+ }
90
+
91
+ return {
92
+ plan: (d[db.plan] as string) ?? 'free',
93
+ billingCycle: normalizeBillingCycle((d[db.billingCycle] as string) ?? 'monthly'),
94
+ subscriptionId: d[db.subscriptionId] as string | undefined,
95
+ subscriptionStatus: normalizeStatus((d[db.subscriptionStatus] as string) ?? 'none'),
96
+ polarCustomerId: d[db.polarCustomerId] as string | undefined,
97
+ cancelAtPeriodEnd: d[db.cancelAtPeriodEnd] as boolean | undefined,
98
+ currentPeriodEnd,
99
+ };
100
+ },
101
+
102
+ async createCheckout(params: CheckoutParams): Promise<CheckoutResult> {
103
+ return callable<CheckoutParams, CheckoutResult>(callables.createCheckout, params);
104
+ },
105
+
106
+ async syncSubscription(_userId: string, _checkoutId?: string): Promise<SyncResult> {
107
+ return callable<Record<string, never>, SyncResult>(callables.sync, {});
108
+ },
109
+
110
+ async getBillingHistory(_userId: string): Promise<OrderItem[]> {
111
+ const result = await callable<Record<string, never>, { orders?: OrderItem[] }>(
112
+ callables.billing,
113
+ {},
114
+ );
115
+ return result.orders ?? [];
116
+ },
117
+
118
+ async cancelSubscription(reason?: CancellationReason): Promise<CancelResult> {
119
+ return callable<{ reason?: string }, CancelResult>(callables.cancel, { reason });
120
+ },
121
+
122
+ async getPortalUrl(_userId: string): Promise<string> {
123
+ const result = await callable<Record<string, never>, { url?: string; customerPortalUrl?: string }>(
124
+ callables.portal,
125
+ {},
126
+ );
127
+ const url = result.url ?? result.customerPortalUrl;
128
+ if (!url) throw new Error('No portal URL returned from Cloud Function');
129
+ return url;
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,31 @@
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.
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
+ return map[raw?.toLowerCase()] ?? 'none';
21
+ }
22
+
23
+ /**
24
+ * Normalize billing interval
25
+ * @description Maps 'month'/'year' to 'monthly'/'yearly'
26
+ */
27
+ export function normalizeBillingCycle(interval: string): BillingCycle {
28
+ if (interval === 'month' || interval === 'monthly') return 'monthly';
29
+ if (interval === 'year' || interval === 'yearly') return 'yearly';
30
+ return 'monthly';
31
+ }
@@ -0,0 +1,128 @@
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
+ export function PolarProvider({ adapter, userId, children }: PolarProviderProps) {
37
+ const [status, setStatus] = useState<SubscriptionStatus>(FREE_STATUS);
38
+ const [loading, setLoading] = useState(true);
39
+ const adapterRef = useRef(adapter);
40
+ adapterRef.current = adapter;
41
+ const refreshAbortRef = useRef<AbortController | null>(null);
42
+
43
+ const refresh = useCallback(async () => {
44
+ const uid = userId?.trim();
45
+ if (!uid) {
46
+ setStatus(FREE_STATUS);
47
+ setLoading(false);
48
+ return;
49
+ }
50
+
51
+ refreshAbortRef.current?.abort();
52
+ const ctrl = new AbortController();
53
+ refreshAbortRef.current = ctrl;
54
+
55
+ try {
56
+ setLoading(true);
57
+ const s = await adapterRef.current.getStatus(uid);
58
+ if (!ctrl.signal.aborted) setStatus(s);
59
+ } catch (err) {
60
+ if (!ctrl.signal.aborted) {
61
+ console.error('[polar-billing] getStatus failed:', err);
62
+ setStatus(FREE_STATUS);
63
+ }
64
+ } finally {
65
+ if (!ctrl.signal.aborted) setLoading(false);
66
+ }
67
+ }, [userId]);
68
+
69
+ useEffect(() => {
70
+ refresh();
71
+ return () => { refreshAbortRef.current?.abort(); };
72
+ }, [refresh]);
73
+
74
+ const startCheckout = useCallback(async (params: CheckoutParams) => {
75
+ const result = await adapterRef.current.createCheckout({ ...params, userId: userId?.trim() });
76
+ if (!result.url.startsWith('https://')) {
77
+ throw new Error('Invalid checkout URL returned');
78
+ }
79
+ window.location.href = result.url;
80
+ }, [userId]);
81
+
82
+ const syncSubscription = useCallback(async (): Promise<SyncResult> => {
83
+ const uid = userId?.trim();
84
+ if (!uid) return { synced: false };
85
+
86
+ const checkoutId = new URLSearchParams(window.location.search).get('checkout_id') ?? undefined;
87
+ const result = await adapterRef.current.syncSubscription(uid, checkoutId);
88
+ if (result.synced) await refresh();
89
+ return result;
90
+ }, [userId, refresh]);
91
+
92
+ const getBillingHistory = useCallback(async (): Promise<OrderItem[]> => {
93
+ const uid = userId?.trim();
94
+ if (!uid) return [];
95
+ return adapterRef.current.getBillingHistory(uid);
96
+ }, [userId]);
97
+
98
+ const cancelSubscription = useCallback(
99
+ async (reason?: CancellationReason): Promise<CancelResult> => {
100
+ const result = await adapterRef.current.cancelSubscription(reason);
101
+ if (result.success) await refresh();
102
+ return result;
103
+ },
104
+ [refresh],
105
+ );
106
+
107
+ const getPortalUrl = useCallback(async (): Promise<string> => {
108
+ const uid = userId?.trim();
109
+ if (!uid) throw new Error('No authenticated user');
110
+ return adapterRef.current.getPortalUrl(uid);
111
+ }, [userId]);
112
+
113
+ const value = useMemo(
114
+ () => ({
115
+ status,
116
+ loading,
117
+ refresh,
118
+ startCheckout,
119
+ syncSubscription,
120
+ getBillingHistory,
121
+ cancelSubscription,
122
+ getPortalUrl,
123
+ }),
124
+ [status, loading, refresh, startCheckout, syncSubscription, getBillingHistory, cancelSubscription, getPortalUrl],
125
+ );
126
+
127
+ return <PolarContext.Provider value={value}>{children}</PolarContext.Provider>;
128
+ }
@@ -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';