@umituz/react-native-subscription 1.10.1 → 2.1.0

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "1.10.1",
4
- "description": "Subscription management, paywall UI and credits system for React Native apps",
3
+ "version": "2.1.0",
4
+ "description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "scripts": {
@@ -36,7 +36,8 @@
36
36
  "@umituz/react-native-design-system-atoms": "*",
37
37
  "@umituz/react-native-design-system-theme": "*",
38
38
  "@umituz/react-native-legal": "*",
39
- "firebase": ">=10.0.0"
39
+ "firebase": ">=10.0.0",
40
+ "expo-constants": ">=18.0.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/react": "~19.1.0",
@@ -45,7 +46,9 @@
45
46
  "@umituz/react-native-design-system-theme": "latest",
46
47
  "@umituz/react-native-firestore": "latest",
47
48
  "@tanstack/react-query": "^5.0.0",
48
- "firebase": "^11.0.0"
49
+ "firebase": "^11.0.0",
50
+ "expo-constants": "~18.0.0",
51
+ "react-native-purchases": "~9.6.0"
49
52
  },
50
53
  "publishConfig": {
51
54
  "access": "public"
package/src/index.ts CHANGED
@@ -254,3 +254,65 @@ export {
254
254
  type CreditCheckerConfig,
255
255
  type CreditChecker,
256
256
  } from "./utils/creditChecker";
257
+
258
+ // =============================================================================
259
+ // REVENUECAT - Errors
260
+ // =============================================================================
261
+
262
+ export {
263
+ RevenueCatError,
264
+ RevenueCatInitializationError,
265
+ RevenueCatConfigurationError,
266
+ RevenueCatPurchaseError,
267
+ RevenueCatRestoreError,
268
+ RevenueCatNetworkError,
269
+ RevenueCatExpoGoError,
270
+ } from "./revenuecat/domain/errors/RevenueCatError";
271
+
272
+ // =============================================================================
273
+ // REVENUECAT - Types & Config
274
+ // =============================================================================
275
+
276
+ export type { RevenueCatConfig } from "./revenuecat/domain/value-objects/RevenueCatConfig";
277
+
278
+ export type {
279
+ RevenueCatEntitlement,
280
+ RevenueCatPurchaseErrorInfo,
281
+ } from "./revenuecat/domain/types/RevenueCatTypes";
282
+
283
+ export { REVENUECAT_LOG_PREFIX } from "./revenuecat/domain/constants/RevenueCatConstants";
284
+
285
+ export {
286
+ getPremiumEntitlement,
287
+ isUserCancelledError,
288
+ getErrorMessage,
289
+ } from "./revenuecat/domain/types/RevenueCatTypes";
290
+
291
+ // =============================================================================
292
+ // REVENUECAT - Ports
293
+ // =============================================================================
294
+
295
+ export type {
296
+ IRevenueCatService,
297
+ InitializeResult,
298
+ PurchaseResult,
299
+ RestoreResult,
300
+ } from "./revenuecat/application/ports/IRevenueCatService";
301
+
302
+ // =============================================================================
303
+ // REVENUECAT - Service
304
+ // =============================================================================
305
+
306
+ export {
307
+ RevenueCatService,
308
+ initializeRevenueCatService,
309
+ getRevenueCatService,
310
+ resetRevenueCatService,
311
+ } from "./revenuecat/infrastructure/services/RevenueCatService";
312
+
313
+ // =============================================================================
314
+ // REVENUECAT - Hooks
315
+ // =============================================================================
316
+
317
+ export { useRevenueCat } from "./revenuecat/presentation/hooks/useRevenueCat";
318
+ export type { UseRevenueCatResult } from "./revenuecat/presentation/hooks/useRevenueCat";
@@ -28,6 +28,8 @@
28
28
  import { useCallback } from "react";
29
29
  import { useCredits } from "./useCredits";
30
30
 
31
+ declare const __DEV__: boolean;
32
+
31
33
  export interface UseFeatureGateParams {
32
34
  /** User ID for credits check */
33
35
  userId: string | undefined;
@@ -66,12 +68,39 @@ export function useFeatureGate(
66
68
  // User is premium if they have credits
67
69
  const isPremium = credits !== null;
68
70
 
71
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
72
+ // eslint-disable-next-line no-console
73
+ console.log("[useFeatureGate] Hook state", {
74
+ userId,
75
+ isAuthenticated,
76
+ isPremium,
77
+ hasCredits: credits !== null,
78
+ isLoading,
79
+ });
80
+ }
81
+
69
82
  const requireFeature = useCallback(
70
83
  (action: () => void | Promise<void>) => {
84
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
85
+ // eslint-disable-next-line no-console
86
+ console.log("[useFeatureGate] requireFeature() called", {
87
+ isAuthenticated,
88
+ isPremium,
89
+ });
90
+ }
91
+
71
92
  // Step 1: Check authentication
72
93
  if (!isAuthenticated) {
94
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
95
+ // eslint-disable-next-line no-console
96
+ console.log("[useFeatureGate] NOT authenticated → showing auth modal");
97
+ }
73
98
  // After auth, re-check premium before executing
74
99
  onShowAuthModal(() => {
100
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
101
+ // eslint-disable-next-line no-console
102
+ console.log("[useFeatureGate] Auth modal callback → executing action");
103
+ }
75
104
  // This callback runs after successful auth
76
105
  // The component will re-render with new auth state
77
106
  // and user can try the action again
@@ -82,11 +111,19 @@ export function useFeatureGate(
82
111
 
83
112
  // Step 2: Check premium (has credits from TanStack Query)
84
113
  if (!isPremium) {
114
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
115
+ // eslint-disable-next-line no-console
116
+ console.log("[useFeatureGate] NOT premium → showing paywall");
117
+ }
85
118
  onShowPaywall();
86
119
  return;
87
120
  }
88
121
 
89
122
  // Step 3: User is authenticated and premium - execute action
123
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
124
+ // eslint-disable-next-line no-console
125
+ console.log("[useFeatureGate] PREMIUM user → executing action");
126
+ }
90
127
  action();
91
128
  },
92
129
  [isAuthenticated, isPremium, onShowAuthModal, onShowPaywall]
@@ -0,0 +1,64 @@
1
+ /**
2
+ * RevenueCat Service Interface
3
+ * Port for subscription operations
4
+ */
5
+
6
+ import type { PurchasesPackage, PurchasesOffering, CustomerInfo } from "react-native-purchases";
7
+
8
+ export interface InitializeResult {
9
+ success: boolean;
10
+ offering: PurchasesOffering | null;
11
+ hasPremium: boolean;
12
+ }
13
+
14
+ export interface PurchaseResult {
15
+ success: boolean;
16
+ isPremium: boolean;
17
+ customerInfo?: CustomerInfo;
18
+ isConsumable?: boolean;
19
+ productId?: string;
20
+ }
21
+
22
+ export interface RestoreResult {
23
+ success: boolean;
24
+ isPremium: boolean;
25
+ customerInfo?: CustomerInfo;
26
+ }
27
+
28
+ export interface IRevenueCatService {
29
+ /**
30
+ * Initialize RevenueCat SDK
31
+ */
32
+ initialize(userId: string, apiKey: string): Promise<InitializeResult>;
33
+
34
+ /**
35
+ * Fetch offerings from RevenueCat
36
+ */
37
+ fetchOfferings(): Promise<PurchasesOffering | null>;
38
+
39
+ /**
40
+ * Purchase a package
41
+ */
42
+ purchasePackage(pkg: PurchasesPackage, userId: string): Promise<PurchaseResult>;
43
+
44
+ /**
45
+ * Restore purchases
46
+ */
47
+ restorePurchases(userId: string): Promise<RestoreResult>;
48
+
49
+ /**
50
+ * Reset RevenueCat SDK (for logout)
51
+ */
52
+ reset(): Promise<void>;
53
+
54
+ /**
55
+ * Get RevenueCat API key for current platform
56
+ */
57
+ getRevenueCatKey(): string | null;
58
+
59
+ /**
60
+ * Check if RevenueCat is initialized
61
+ */
62
+ isInitialized(): boolean;
63
+ }
64
+
@@ -0,0 +1,6 @@
1
+ /**
2
+ * RevenueCat Constants
3
+ * Internal constants for logging
4
+ */
5
+
6
+ export const REVENUECAT_LOG_PREFIX = "[RevenueCat]";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * RevenueCat Error Classes
3
+ * Domain-specific error types for RevenueCat operations
4
+ */
5
+
6
+ export class RevenueCatError extends Error {
7
+ constructor(message: string) {
8
+ super(message);
9
+ this.name = "RevenueCatError";
10
+ }
11
+ }
12
+
13
+ export class RevenueCatInitializationError extends RevenueCatError {
14
+ constructor(message = "RevenueCat service is not initialized") {
15
+ super(message);
16
+ this.name = "RevenueCatInitializationError";
17
+ }
18
+ }
19
+
20
+ export class RevenueCatConfigurationError extends RevenueCatError {
21
+ constructor(message = "RevenueCat configuration is invalid") {
22
+ super(message);
23
+ this.name = "RevenueCatConfigurationError";
24
+ }
25
+ }
26
+
27
+ export class RevenueCatPurchaseError extends RevenueCatError {
28
+ public productId: string | undefined;
29
+
30
+ constructor(message: string, productId?: string) {
31
+ super(message);
32
+ this.name = "RevenueCatPurchaseError";
33
+ this.productId = productId;
34
+ }
35
+ }
36
+
37
+ export class RevenueCatRestoreError extends RevenueCatError {
38
+ constructor(message = "Failed to restore purchases") {
39
+ super(message);
40
+ this.name = "RevenueCatRestoreError";
41
+ }
42
+ }
43
+
44
+ export class RevenueCatNetworkError extends RevenueCatError {
45
+ constructor(message = "Network error during RevenueCat operation") {
46
+ super(message);
47
+ this.name = "RevenueCatNetworkError";
48
+ }
49
+ }
50
+
51
+ export class RevenueCatExpoGoError extends RevenueCatError {
52
+ constructor(message = "RevenueCat is not available in Expo Go. Use a development build or test store.") {
53
+ super(message);
54
+ this.name = "RevenueCatExpoGoError";
55
+ }
56
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * RevenueCat Type Definitions
3
+ * Proper typing for RevenueCat entitlements and errors
4
+ */
5
+
6
+ import type { CustomerInfo } from "react-native-purchases";
7
+
8
+ /**
9
+ * RevenueCat Entitlement Info
10
+ * Represents active entitlement data from CustomerInfo
11
+ */
12
+ export interface RevenueCatEntitlement {
13
+ identifier: string;
14
+ productIdentifier: string;
15
+ isSandbox: boolean;
16
+ willRenew: boolean;
17
+ periodType: string;
18
+ latestPurchaseDate: string | null;
19
+ originalPurchaseDate: string | null;
20
+ expirationDate: string | null;
21
+ unsubscribeDetectedAt: string | null;
22
+ billingIssueDetectedAt: string | null;
23
+ }
24
+
25
+ /**
26
+ * RevenueCat Purchase Error with userCancelled flag
27
+ */
28
+ export interface RevenueCatPurchaseErrorInfo extends Error {
29
+ userCancelled?: boolean;
30
+ code?: string;
31
+ }
32
+
33
+ /**
34
+ * Extract entitlement from CustomerInfo
35
+ */
36
+ export function getPremiumEntitlement(
37
+ customerInfo: CustomerInfo,
38
+ entitlementIdentifier: string = 'premium'
39
+ ): RevenueCatEntitlement | null {
40
+ const entitlement = customerInfo.entitlements.active[entitlementIdentifier];
41
+ if (!entitlement) {
42
+ return null;
43
+ }
44
+
45
+ return {
46
+ identifier: entitlement.identifier,
47
+ productIdentifier: entitlement.productIdentifier,
48
+ isSandbox: entitlement.isSandbox,
49
+ willRenew: entitlement.willRenew,
50
+ periodType: entitlement.periodType,
51
+ latestPurchaseDate: entitlement.latestPurchaseDate,
52
+ originalPurchaseDate: entitlement.originalPurchaseDate,
53
+ expirationDate: entitlement.expirationDate,
54
+ unsubscribeDetectedAt: entitlement.unsubscribeDetectedAt,
55
+ billingIssueDetectedAt: entitlement.billingIssueDetectedAt,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Check if error is a user cancellation
61
+ */
62
+ export function isUserCancelledError(error: unknown): boolean {
63
+ if (!error || typeof error !== "object") {
64
+ return false;
65
+ }
66
+ return (error as RevenueCatPurchaseErrorInfo).userCancelled === true;
67
+ }
68
+
69
+ /**
70
+ * Extract error message safely
71
+ */
72
+ export function getErrorMessage(error: unknown, fallback: string): string {
73
+ if (error instanceof Error) {
74
+ return error.message;
75
+ }
76
+ return fallback;
77
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * RevenueCat Configuration Value Object
3
+ * Validates and stores RevenueCat configuration
4
+ */
5
+
6
+ import type { CustomerInfo } from "react-native-purchases";
7
+
8
+ export interface RevenueCatConfig {
9
+ /** iOS API key */
10
+ iosApiKey?: string;
11
+ /** Android API key */
12
+ androidApiKey?: string;
13
+ /** Test Store key for development/Expo Go testing */
14
+ testStoreKey?: string;
15
+ /** Entitlement identifier to check for premium status (REQUIRED - app specific) */
16
+ entitlementIdentifier: string;
17
+ /** Product identifiers for consumable products (e.g., credits packages) */
18
+ consumableProductIdentifiers?: string[];
19
+ /** Callback for premium status sync to database */
20
+ onPremiumStatusChanged?: (
21
+ userId: string,
22
+ isPremium: boolean,
23
+ productId?: string,
24
+ expiresAt?: string
25
+ ) => Promise<void> | void;
26
+ /** Callback for purchase completion */
27
+ onPurchaseCompleted?: (
28
+ userId: string,
29
+ productId: string,
30
+ customerInfo: CustomerInfo
31
+ ) => Promise<void> | void;
32
+ /** Callback for restore completion */
33
+ onRestoreCompleted?: (
34
+ userId: string,
35
+ isPremium: boolean,
36
+ customerInfo: CustomerInfo
37
+ ) => Promise<void> | void;
38
+ }
39
+
40
+ export interface RevenueCatConfigRequired {
41
+ iosApiKey: string;
42
+ androidApiKey: string;
43
+ }
44
+
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Customer Info Listener Manager
3
+ * Handles RevenueCat customer info update listeners
4
+ */
5
+
6
+ import Purchases, {
7
+ type CustomerInfo,
8
+ type CustomerInfoUpdateListener,
9
+ } from "react-native-purchases";
10
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
11
+ import { syncPremiumStatus } from "../utils/PremiumStatusSyncer";
12
+
13
+ export class CustomerInfoListenerManager {
14
+ private listener: CustomerInfoUpdateListener | null = null;
15
+ private currentUserId: string | null = null;
16
+ private entitlementIdentifier: string;
17
+
18
+ constructor(entitlementIdentifier: string) {
19
+ this.entitlementIdentifier = entitlementIdentifier;
20
+ }
21
+
22
+ setUserId(userId: string): void {
23
+ this.currentUserId = userId;
24
+ }
25
+
26
+ clearUserId(): void {
27
+ this.currentUserId = null;
28
+ }
29
+
30
+ setupListener(config: RevenueCatConfig): void {
31
+ this.removeListener();
32
+
33
+ this.listener = (customerInfo: CustomerInfo) => {
34
+ if (!this.currentUserId) return;
35
+
36
+ const hasPremium =
37
+ !!customerInfo.entitlements.active[this.entitlementIdentifier];
38
+
39
+ if (__DEV__) {
40
+ console.log("[RevenueCat] CustomerInfo updated", {
41
+ userId: this.currentUserId,
42
+ hasPremium,
43
+ entitlementIdentifier: this.entitlementIdentifier,
44
+ });
45
+ }
46
+
47
+ syncPremiumStatus(config, this.currentUserId, customerInfo);
48
+ };
49
+
50
+ Purchases.addCustomerInfoUpdateListener(this.listener);
51
+ }
52
+
53
+ removeListener(): void {
54
+ if (this.listener) {
55
+ Purchases.removeCustomerInfoUpdateListener(this.listener);
56
+ this.listener = null;
57
+ }
58
+ }
59
+
60
+ destroy(): void {
61
+ this.removeListener();
62
+ this.clearUserId();
63
+ }
64
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Offerings Fetcher
3
+ * Handles RevenueCat offerings retrieval
4
+ */
5
+
6
+ import Purchases, { type PurchasesOffering } from "react-native-purchases";
7
+ import { isExpoGo } from "../utils/ExpoGoDetector";
8
+
9
+ export interface OfferingsFetcherDeps {
10
+ isInitialized: () => boolean;
11
+ isUsingTestStore: () => boolean;
12
+ }
13
+
14
+ export async function fetchOfferings(
15
+ deps: OfferingsFetcherDeps
16
+ ): Promise<PurchasesOffering | null> {
17
+ if (__DEV__) {
18
+ console.log(
19
+ "[RevenueCat] fetchOfferings() called, isInitialized:",
20
+ deps.isInitialized()
21
+ );
22
+ }
23
+
24
+ if (!deps.isInitialized()) {
25
+ if (__DEV__) {
26
+ console.log("[RevenueCat] fetchOfferings() - NOT initialized");
27
+ }
28
+ return null;
29
+ }
30
+
31
+ if (isExpoGo() && !deps.isUsingTestStore()) {
32
+ if (__DEV__) {
33
+ console.log("[RevenueCat] fetchOfferings() - ExpoGo without test store");
34
+ }
35
+ return null;
36
+ }
37
+
38
+ try {
39
+ const offerings = await Purchases.getOfferings();
40
+
41
+ if (__DEV__) {
42
+ console.log("[RevenueCat] fetchOfferings() result:", {
43
+ hasCurrent: !!offerings.current,
44
+ packagesCount: offerings.current?.availablePackages?.length ?? 0,
45
+ });
46
+ }
47
+
48
+ return offerings.current;
49
+ } catch (error) {
50
+ if (__DEV__) {
51
+ console.log("[RevenueCat] fetchOfferings() error:", error);
52
+ }
53
+ return null;
54
+ }
55
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Purchase Handler
3
+ * Handles RevenueCat purchase operations for both subscriptions and consumables
4
+ */
5
+
6
+ import Purchases, { type PurchasesPackage } from "react-native-purchases";
7
+ import type { PurchaseResult } from "../../application/ports/IRevenueCatService";
8
+ import {
9
+ RevenueCatPurchaseError,
10
+ RevenueCatExpoGoError,
11
+ RevenueCatInitializationError,
12
+ } from "../../domain/errors/RevenueCatError";
13
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
14
+ import {
15
+ isUserCancelledError,
16
+ getErrorMessage,
17
+ } from "../../domain/types/RevenueCatTypes";
18
+ import { isExpoGo } from "../utils/ExpoGoDetector";
19
+ import {
20
+ syncPremiumStatus,
21
+ notifyPurchaseCompleted,
22
+ } from "../utils/PremiumStatusSyncer";
23
+
24
+ export interface PurchaseHandlerDeps {
25
+ config: RevenueCatConfig;
26
+ isInitialized: () => boolean;
27
+ isUsingTestStore: () => boolean;
28
+ }
29
+
30
+ function isConsumableProduct(
31
+ pkg: PurchasesPackage,
32
+ consumableIds: string[]
33
+ ): boolean {
34
+ if (consumableIds.length === 0) return false;
35
+ const identifier = pkg.product.identifier.toLowerCase();
36
+ return consumableIds.some((id) => identifier.includes(id.toLowerCase()));
37
+ }
38
+
39
+ /**
40
+ * Handle package purchase - supports both subscriptions and consumables
41
+ */
42
+ export async function handlePurchase(
43
+ deps: PurchaseHandlerDeps,
44
+ pkg: PurchasesPackage,
45
+ userId: string
46
+ ): Promise<PurchaseResult> {
47
+ if (__DEV__) {
48
+ console.log("[RevenueCat] handlePurchase() called for:", pkg.product.identifier);
49
+ }
50
+
51
+ if (!deps.isInitialized()) {
52
+ throw new RevenueCatInitializationError();
53
+ }
54
+
55
+ if (isExpoGo() && !deps.isUsingTestStore()) {
56
+ throw new RevenueCatExpoGoError();
57
+ }
58
+
59
+ const consumableIds = deps.config.consumableProductIdentifiers || [];
60
+ const isConsumable = isConsumableProduct(pkg, consumableIds);
61
+
62
+ try {
63
+ const purchaseResult = await Purchases.purchasePackage(pkg);
64
+ const customerInfo = purchaseResult.customerInfo;
65
+
66
+ // For consumable products (credits), purchase success is enough
67
+ if (isConsumable) {
68
+ return {
69
+ success: true,
70
+ isPremium: false,
71
+ customerInfo,
72
+ isConsumable: true,
73
+ productId: pkg.product.identifier,
74
+ };
75
+ }
76
+
77
+ // For subscriptions, check premium entitlement
78
+ const entitlementIdentifier = deps.config.entitlementIdentifier;
79
+ const isPremium = !!customerInfo.entitlements.active[entitlementIdentifier];
80
+
81
+ if (isPremium) {
82
+ await syncPremiumStatus(deps.config, userId, customerInfo);
83
+ await notifyPurchaseCompleted(
84
+ deps.config,
85
+ userId,
86
+ pkg.product.identifier,
87
+ customerInfo
88
+ );
89
+ return { success: true, isPremium: true, customerInfo };
90
+ }
91
+
92
+ throw new RevenueCatPurchaseError(
93
+ "Purchase completed but premium entitlement not active",
94
+ pkg.product.identifier
95
+ );
96
+ } catch (error) {
97
+ if (isUserCancelledError(error)) {
98
+ return { success: false, isPremium: false };
99
+ }
100
+ const errorMessage = getErrorMessage(error, "Purchase failed");
101
+ throw new RevenueCatPurchaseError(errorMessage, pkg.product.identifier);
102
+ }
103
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Restore Handler
3
+ * Handles RevenueCat restore operations
4
+ */
5
+
6
+ import Purchases from "react-native-purchases";
7
+ import type { RestoreResult } from "../../application/ports/IRevenueCatService";
8
+ import {
9
+ RevenueCatRestoreError,
10
+ RevenueCatExpoGoError,
11
+ RevenueCatInitializationError,
12
+ } from "../../domain/errors/RevenueCatError";
13
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
14
+ import { getErrorMessage } from "../../domain/types/RevenueCatTypes";
15
+ import { isExpoGo } from "../utils/ExpoGoDetector";
16
+ import {
17
+ syncPremiumStatus,
18
+ notifyRestoreCompleted,
19
+ } from "../utils/PremiumStatusSyncer";
20
+
21
+ export interface RestoreHandlerDeps {
22
+ config: RevenueCatConfig;
23
+ isInitialized: () => boolean;
24
+ isUsingTestStore: () => boolean;
25
+ }
26
+
27
+ /**
28
+ * Handle restore purchases
29
+ */
30
+ export async function handleRestore(
31
+ deps: RestoreHandlerDeps,
32
+ userId: string
33
+ ): Promise<RestoreResult> {
34
+ if (!deps.isInitialized()) {
35
+ throw new RevenueCatInitializationError();
36
+ }
37
+
38
+ if (isExpoGo() && !deps.isUsingTestStore()) {
39
+ throw new RevenueCatExpoGoError();
40
+ }
41
+
42
+ try {
43
+ const customerInfo = await Purchases.restorePurchases();
44
+ const entitlementIdentifier = deps.config.entitlementIdentifier;
45
+ const isPremium = !!customerInfo.entitlements.active[entitlementIdentifier];
46
+
47
+ if (isPremium) {
48
+ await syncPremiumStatus(deps.config, userId, customerInfo);
49
+ }
50
+ await notifyRestoreCompleted(deps.config, userId, isPremium, customerInfo);
51
+
52
+ return { success: isPremium, isPremium, customerInfo };
53
+ } catch (error) {
54
+ const errorMessage = getErrorMessage(error, "Restore failed");
55
+ throw new RevenueCatRestoreError(errorMessage);
56
+ }
57
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * RevenueCat Initializer
3
+ * Handles SDK initialization logic
4
+ */
5
+
6
+ import Purchases from "react-native-purchases";
7
+ import type { InitializeResult } from "../../application/ports/IRevenueCatService";
8
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
9
+ import { getErrorMessage } from "../../domain/types/RevenueCatTypes";
10
+ import { isExpoGo } from "../utils/ExpoGoDetector";
11
+ import { resolveApiKey } from "../utils/ApiKeyResolver";
12
+
13
+ export interface InitializerDeps {
14
+ config: RevenueCatConfig;
15
+ isUsingTestStore: () => boolean;
16
+ isInitialized: () => boolean;
17
+ getCurrentUserId: () => string | null;
18
+ setInitialized: (value: boolean) => void;
19
+ setCurrentUserId: (userId: string) => void;
20
+ }
21
+
22
+ export async function initializeSDK(
23
+ deps: InitializerDeps,
24
+ userId: string,
25
+ apiKey?: string
26
+ ): Promise<InitializeResult> {
27
+ if (__DEV__) {
28
+ console.log("[RevenueCat] initializeSDK() called with userId:", userId);
29
+ }
30
+
31
+ // Check if already initialized with same userId - skip re-configuration
32
+ if (deps.isInitialized()) {
33
+ const currentUserId = deps.getCurrentUserId();
34
+ if (currentUserId === userId) {
35
+ if (__DEV__) {
36
+ console.log("[RevenueCat] Already initialized with same userId, skipping configure");
37
+ }
38
+ // Just fetch current state without re-configuring
39
+ try {
40
+ const [customerInfo, offerings] = await Promise.all([
41
+ Purchases.getCustomerInfo(),
42
+ Purchases.getOfferings(),
43
+ ]);
44
+ const entitlementId = deps.config.entitlementIdentifier;
45
+ const hasPremium = !!customerInfo.entitlements.active[entitlementId];
46
+ return { success: true, offering: offerings.current, hasPremium };
47
+ } catch (error) {
48
+ if (__DEV__) {
49
+ console.log("[RevenueCat] Failed to get current state:", error);
50
+ }
51
+ return { success: false, offering: null, hasPremium: false };
52
+ }
53
+ } else {
54
+ if (__DEV__) {
55
+ console.log("[RevenueCat] Different userId, will re-configure");
56
+ }
57
+ // Different userId - need to logout first
58
+ try {
59
+ await Purchases.logOut();
60
+ } catch {
61
+ // Ignore logout errors
62
+ }
63
+ }
64
+ }
65
+
66
+ if (isExpoGo() && !deps.isUsingTestStore()) {
67
+ if (__DEV__) {
68
+ console.log("[RevenueCat] Skipping - ExpoGo without test store");
69
+ }
70
+ return { success: false, offering: null, hasPremium: false };
71
+ }
72
+
73
+ const key = apiKey || resolveApiKey(deps.config);
74
+ if (!key) {
75
+ if (__DEV__) {
76
+ console.log("[RevenueCat] No API key available");
77
+ }
78
+ return { success: false, offering: null, hasPremium: false };
79
+ }
80
+
81
+ try {
82
+ if (deps.isUsingTestStore()) {
83
+ if (__DEV__) {
84
+ console.log("[RevenueCat] Using Test Store key");
85
+ }
86
+ }
87
+
88
+ if (__DEV__) {
89
+ console.log("[RevenueCat] Calling Purchases.configure()...");
90
+ }
91
+
92
+ await Purchases.configure({ apiKey: key, appUserID: userId });
93
+ deps.setInitialized(true);
94
+ deps.setCurrentUserId(userId);
95
+
96
+ if (__DEV__) {
97
+ console.log("[RevenueCat] SDK configured successfully");
98
+ }
99
+
100
+ const [customerInfo, offerings] = await Promise.all([
101
+ Purchases.getCustomerInfo(),
102
+ Purchases.getOfferings(),
103
+ ]);
104
+
105
+ if (__DEV__) {
106
+ console.log("[RevenueCat] Fetched offerings:", {
107
+ hasCurrent: !!offerings.current,
108
+ packagesCount: offerings.current?.availablePackages?.length ?? 0,
109
+ allOfferingsCount: Object.keys(offerings.all).length,
110
+ });
111
+ }
112
+
113
+ const entitlementId = deps.config.entitlementIdentifier;
114
+ const hasPremium = !!customerInfo.entitlements.active[entitlementId];
115
+
116
+ return { success: true, offering: offerings.current, hasPremium };
117
+ } catch (error) {
118
+ const errorMessage = getErrorMessage(error, "RevenueCat init failed");
119
+ if (__DEV__) {
120
+ console.log("[RevenueCat] Init failed:", errorMessage);
121
+ }
122
+ return { success: false, offering: null, hasPremium: false };
123
+ }
124
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * RevenueCat Service Implementation
3
+ * Main service class for RevenueCat operations
4
+ */
5
+
6
+ import Purchases from "react-native-purchases";
7
+ import type { PurchasesOffering, PurchasesPackage } from "react-native-purchases";
8
+ import type {
9
+ IRevenueCatService,
10
+ InitializeResult,
11
+ PurchaseResult,
12
+ RestoreResult,
13
+ } from "../../application/ports/IRevenueCatService";
14
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
15
+ import { resolveApiKey } from "../utils/ApiKeyResolver";
16
+ import { initializeSDK } from "./RevenueCatInitializer";
17
+ import { fetchOfferings } from "./OfferingsFetcher";
18
+ import { handlePurchase } from "./PurchaseHandler";
19
+ import { handleRestore } from "./RestoreHandler";
20
+ import { CustomerInfoListenerManager } from "./CustomerInfoListenerManager";
21
+ import { ServiceStateManager } from "./ServiceStateManager";
22
+
23
+ export class RevenueCatService implements IRevenueCatService {
24
+ private stateManager: ServiceStateManager;
25
+ private listenerManager: CustomerInfoListenerManager;
26
+
27
+ constructor(config: RevenueCatConfig) {
28
+ this.stateManager = new ServiceStateManager(config);
29
+ this.listenerManager = new CustomerInfoListenerManager(
30
+ config.entitlementIdentifier
31
+ );
32
+ }
33
+
34
+ getRevenueCatKey(): string | null {
35
+ return resolveApiKey(this.stateManager.getConfig());
36
+ }
37
+
38
+ isInitialized(): boolean {
39
+ return this.stateManager.isInitialized();
40
+ }
41
+
42
+ isUsingTestStore(): boolean {
43
+ return this.stateManager.isUsingTestStore();
44
+ }
45
+
46
+ async initialize(userId: string, apiKey?: string): Promise<InitializeResult> {
47
+ const result = await initializeSDK(
48
+ {
49
+ config: this.stateManager.getConfig(),
50
+ isUsingTestStore: () => this.isUsingTestStore(),
51
+ isInitialized: () => this.isInitialized(),
52
+ getCurrentUserId: () => this.stateManager.getCurrentUserId(),
53
+ setInitialized: (value) => this.stateManager.setInitialized(value),
54
+ setCurrentUserId: (id) => this.stateManager.setCurrentUserId(id),
55
+ },
56
+ userId,
57
+ apiKey
58
+ );
59
+
60
+ if (result.success) {
61
+ this.listenerManager.setUserId(userId);
62
+ this.listenerManager.setupListener(this.stateManager.getConfig());
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ async fetchOfferings(): Promise<PurchasesOffering | null> {
69
+ return fetchOfferings({
70
+ isInitialized: () => this.isInitialized(),
71
+ isUsingTestStore: () => this.isUsingTestStore(),
72
+ });
73
+ }
74
+
75
+ async purchasePackage(
76
+ pkg: PurchasesPackage,
77
+ userId: string
78
+ ): Promise<PurchaseResult> {
79
+ return handlePurchase(
80
+ {
81
+ config: this.stateManager.getConfig(),
82
+ isInitialized: () => this.isInitialized(),
83
+ isUsingTestStore: () => this.isUsingTestStore(),
84
+ },
85
+ pkg,
86
+ userId
87
+ );
88
+ }
89
+
90
+ async restorePurchases(userId: string): Promise<RestoreResult> {
91
+ return handleRestore(
92
+ {
93
+ config: this.stateManager.getConfig(),
94
+ isInitialized: () => this.isInitialized(),
95
+ isUsingTestStore: () => this.isUsingTestStore(),
96
+ },
97
+ userId
98
+ );
99
+ }
100
+
101
+ async reset(): Promise<void> {
102
+ if (!this.isInitialized()) return;
103
+
104
+ this.listenerManager.destroy();
105
+
106
+ try {
107
+ await Purchases.logOut();
108
+ this.stateManager.setInitialized(false);
109
+ } catch {
110
+ // Reset errors are non-critical
111
+ }
112
+ }
113
+ }
114
+
115
+ let revenueCatServiceInstance: RevenueCatService | null = null;
116
+
117
+ export function initializeRevenueCatService(
118
+ config: RevenueCatConfig
119
+ ): RevenueCatService {
120
+ if (!revenueCatServiceInstance) {
121
+ revenueCatServiceInstance = new RevenueCatService(config);
122
+ }
123
+ return revenueCatServiceInstance;
124
+ }
125
+
126
+ export function getRevenueCatService(): RevenueCatService | null {
127
+ return revenueCatServiceInstance;
128
+ }
129
+
130
+ export function resetRevenueCatService(): void {
131
+ revenueCatServiceInstance = null;
132
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Service State Manager
3
+ * Manages RevenueCat service state
4
+ */
5
+
6
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
7
+ import { isExpoGo, isDevelopment } from "../utils/ExpoGoDetector";
8
+
9
+ export class ServiceStateManager {
10
+ private isInitializedFlag: boolean = false;
11
+ private usingTestStore: boolean = false;
12
+ private currentUserId: string | null = null;
13
+ private config: RevenueCatConfig;
14
+
15
+ constructor(config: RevenueCatConfig) {
16
+ this.config = config;
17
+ this.usingTestStore = this.shouldUseTestStore();
18
+
19
+ if (__DEV__) {
20
+ console.log("[RevenueCat] Config", {
21
+ hasTestKey: !!this.config.testStoreKey,
22
+ usingTestStore: this.usingTestStore,
23
+ entitlementIdentifier: this.config.entitlementIdentifier,
24
+ });
25
+ }
26
+ }
27
+
28
+ private shouldUseTestStore(): boolean {
29
+ const testKey = this.config.testStoreKey;
30
+ return !!(testKey && (isExpoGo() || isDevelopment()));
31
+ }
32
+
33
+ setInitialized(value: boolean): void {
34
+ this.isInitializedFlag = value;
35
+ if (!value) {
36
+ this.currentUserId = null;
37
+ }
38
+ }
39
+
40
+ isInitialized(): boolean {
41
+ return this.isInitializedFlag;
42
+ }
43
+
44
+ setCurrentUserId(userId: string): void {
45
+ this.currentUserId = userId;
46
+ }
47
+
48
+ getCurrentUserId(): string | null {
49
+ return this.currentUserId;
50
+ }
51
+
52
+ isUsingTestStore(): boolean {
53
+ return this.usingTestStore;
54
+ }
55
+
56
+ getConfig(): RevenueCatConfig {
57
+ return this.config;
58
+ }
59
+
60
+ updateConfig(config: RevenueCatConfig): void {
61
+ this.config = config;
62
+ this.usingTestStore = this.shouldUseTestStore();
63
+
64
+ if (__DEV__) {
65
+ console.log("[RevenueCat] Config updated", {
66
+ hasTestKey: !!this.config.testStoreKey,
67
+ usingTestStore: this.usingTestStore,
68
+ });
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * API Key Resolver
3
+ * Resolves RevenueCat API key from configuration
4
+ */
5
+
6
+ import { Platform } from "react-native";
7
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
8
+ import { isExpoGo, isDevelopment } from "./ExpoGoDetector";
9
+
10
+ /**
11
+ * Check if Test Store key should be used
12
+ */
13
+ export function shouldUseTestStore(config: RevenueCatConfig): boolean {
14
+ const testKey = config.testStoreKey;
15
+ return !!(testKey && (isExpoGo() || isDevelopment()));
16
+ }
17
+
18
+ /**
19
+ * Get RevenueCat API key from config
20
+ * Returns Test Store key if in dev/Expo Go environment
21
+ */
22
+ export function resolveApiKey(config: RevenueCatConfig): string | null {
23
+ if (shouldUseTestStore(config)) {
24
+ return config.testStoreKey ?? null;
25
+ }
26
+
27
+ const key = Platform.OS === 'ios'
28
+ ? config.iosApiKey
29
+ : Platform.OS === 'android'
30
+ ? config.androidApiKey
31
+ : config.iosApiKey;
32
+
33
+ if (!key || key === "" || key.includes("YOUR_")) {
34
+ return null;
35
+ }
36
+
37
+ return key;
38
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Expiration Date Calculator
3
+ * Handles RevenueCat expiration date extraction
4
+ */
5
+
6
+ import type { RevenueCatEntitlement } from "../../domain/types/RevenueCatTypes";
7
+
8
+ export function getExpirationDate(
9
+ entitlement: RevenueCatEntitlement | null
10
+ ): string | null {
11
+ if (!entitlement) {
12
+ return null;
13
+ }
14
+
15
+ if (!entitlement.expirationDate) {
16
+ return null;
17
+ }
18
+
19
+ return new Date(entitlement.expirationDate).toISOString();
20
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Expo Go Detector
3
+ * Detects runtime environment for RevenueCat configuration
4
+ */
5
+
6
+ import Constants from "expo-constants";
7
+
8
+ /**
9
+ * Check if running in Expo Go
10
+ */
11
+ export function isExpoGo(): boolean {
12
+ return Constants.executionEnvironment === "storeClient";
13
+ }
14
+
15
+ /**
16
+ * Check if running in development mode
17
+ */
18
+ export function isDevelopment(): boolean {
19
+ return typeof __DEV__ !== "undefined" && __DEV__;
20
+ }
21
+
22
+ /**
23
+ * Check if Test Store should be used (Expo Go or development)
24
+ */
25
+ export function isTestStoreEnvironment(): boolean {
26
+ return isExpoGo() || isDevelopment();
27
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Premium Status Syncer
3
+ * Syncs premium status to database via callbacks
4
+ */
5
+
6
+ import type { CustomerInfo } from "react-native-purchases";
7
+ import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
8
+ import { getPremiumEntitlement } from "../../domain/types/RevenueCatTypes";
9
+ import { getExpirationDate } from "./ExpirationDateCalculator";
10
+
11
+ export async function syncPremiumStatus(
12
+ config: RevenueCatConfig,
13
+ userId: string,
14
+ customerInfo: CustomerInfo
15
+ ): Promise<void> {
16
+ if (!config.onPremiumStatusChanged) {
17
+ return;
18
+ }
19
+
20
+ const entitlementIdentifier = config.entitlementIdentifier;
21
+ const premiumEntitlement = getPremiumEntitlement(
22
+ customerInfo,
23
+ entitlementIdentifier
24
+ );
25
+
26
+ try {
27
+ if (premiumEntitlement) {
28
+ const productId = premiumEntitlement.productIdentifier;
29
+ const expiresAt = getExpirationDate(premiumEntitlement);
30
+ await config.onPremiumStatusChanged(
31
+ userId,
32
+ true,
33
+ productId,
34
+ expiresAt || undefined
35
+ );
36
+ } else {
37
+ await config.onPremiumStatusChanged(userId, false);
38
+ }
39
+ } catch (error) {
40
+ if (__DEV__) {
41
+ const message =
42
+ error instanceof Error ? error.message : "Premium sync failed";
43
+ console.log("[RevenueCat] Premium status sync failed:", message);
44
+ }
45
+ }
46
+ }
47
+
48
+ export async function notifyPurchaseCompleted(
49
+ config: RevenueCatConfig,
50
+ userId: string,
51
+ productId: string,
52
+ customerInfo: CustomerInfo
53
+ ): Promise<void> {
54
+ if (!config.onPurchaseCompleted) {
55
+ return;
56
+ }
57
+
58
+ try {
59
+ await config.onPurchaseCompleted(userId, productId, customerInfo);
60
+ } catch (error) {
61
+ if (__DEV__) {
62
+ const message =
63
+ error instanceof Error ? error.message : "Purchase callback failed";
64
+ console.log("[RevenueCat] Purchase completion callback failed:", message);
65
+ }
66
+ }
67
+ }
68
+
69
+ export async function notifyRestoreCompleted(
70
+ config: RevenueCatConfig,
71
+ userId: string,
72
+ isPremium: boolean,
73
+ customerInfo: CustomerInfo
74
+ ): Promise<void> {
75
+ if (!config.onRestoreCompleted) {
76
+ return;
77
+ }
78
+
79
+ try {
80
+ await config.onRestoreCompleted(userId, isPremium, customerInfo);
81
+ } catch (error) {
82
+ if (__DEV__) {
83
+ const message =
84
+ error instanceof Error ? error.message : "Restore callback failed";
85
+ console.log("[RevenueCat] Restore completion callback failed:", message);
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * useRevenueCat Hook
3
+ * React hook for RevenueCat subscription management
4
+ */
5
+
6
+ import { useState, useCallback } from "react";
7
+ import type { PurchasesOffering, PurchasesPackage } from "react-native-purchases";
8
+ import { getRevenueCatService } from "../../infrastructure/services/RevenueCatService";
9
+ import type { PurchaseResult, RestoreResult } from "../../application/ports/IRevenueCatService";
10
+
11
+ export interface UseRevenueCatResult {
12
+ /** Current offering */
13
+ offering: PurchasesOffering | null;
14
+ /** Whether RevenueCat is loading */
15
+ loading: boolean;
16
+ /** Initialize RevenueCat SDK */
17
+ initialize: (userId: string, apiKey?: string) => Promise<void>;
18
+ /** Load offerings */
19
+ loadOfferings: () => Promise<void>;
20
+ /** Purchase a package */
21
+ purchasePackage: (pkg: PurchasesPackage, userId: string) => Promise<PurchaseResult>;
22
+ /** Restore purchases */
23
+ restorePurchases: (userId: string) => Promise<RestoreResult>;
24
+ }
25
+
26
+ /**
27
+ * Hook for RevenueCat operations
28
+ * Only initialize when subscription screen is opened
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * const { offering, loading, initialize, purchasePackage } = useRevenueCat();
33
+ * ```
34
+ */
35
+ export function useRevenueCat(): UseRevenueCatResult {
36
+ const [offering, setOffering] = useState<PurchasesOffering | null>(null);
37
+ const [loading, setLoading] = useState(false);
38
+
39
+ const initialize = useCallback(async (userId: string, apiKey?: string) => {
40
+ setLoading(true);
41
+ try {
42
+ const service = getRevenueCatService();
43
+ if (!service) {
44
+ return;
45
+ }
46
+ const result = await service.initialize(userId, apiKey);
47
+ if (result.success) {
48
+ setOffering(result.offering);
49
+ }
50
+ } catch {
51
+ // Error handling is done by service
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
+ }, []);
56
+
57
+ const loadOfferings = useCallback(async () => {
58
+ setLoading(true);
59
+ try {
60
+ const service = getRevenueCatService();
61
+ if (!service) {
62
+ return;
63
+ }
64
+ const fetchedOffering = await service.fetchOfferings();
65
+ setOffering(fetchedOffering);
66
+ } catch {
67
+ // Error handling is done by service
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ }, []);
72
+
73
+ const purchasePackage = useCallback(async (pkg: PurchasesPackage, userId: string) => {
74
+ const service = getRevenueCatService();
75
+ if (!service) {
76
+ throw new Error("RevenueCat service is not initialized");
77
+ }
78
+ return await service.purchasePackage(pkg, userId);
79
+ }, []);
80
+
81
+ const restorePurchases = useCallback(async (userId: string) => {
82
+ const service = getRevenueCatService();
83
+ if (!service) {
84
+ throw new Error("RevenueCat service is not initialized");
85
+ }
86
+ return await service.restorePurchases(userId);
87
+ }, []);
88
+
89
+ // Note: State cleanup is handled by React automatically on unmount
90
+ // No explicit cleanup needed for these state variables
91
+
92
+ return {
93
+ offering,
94
+ loading,
95
+ initialize,
96
+ loadOfferings,
97
+ purchasePackage,
98
+ restorePurchases,
99
+ };
100
+ }
101
+