@umituz/react-native-subscription 1.10.1 → 2.0.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 +7 -4
- package/src/index.ts +62 -0
- package/src/revenuecat/application/ports/IRevenueCatService.ts +64 -0
- package/src/revenuecat/domain/constants/RevenueCatConstants.ts +6 -0
- package/src/revenuecat/domain/errors/RevenueCatError.ts +56 -0
- package/src/revenuecat/domain/types/RevenueCatTypes.ts +77 -0
- package/src/revenuecat/domain/value-objects/RevenueCatConfig.ts +44 -0
- package/src/revenuecat/infrastructure/services/CustomerInfoListenerManager.ts +64 -0
- package/src/revenuecat/infrastructure/services/OfferingsFetcher.ts +55 -0
- package/src/revenuecat/infrastructure/services/PurchaseHandler.ts +103 -0
- package/src/revenuecat/infrastructure/services/RestoreHandler.ts +57 -0
- package/src/revenuecat/infrastructure/services/RevenueCatInitializer.ts +124 -0
- package/src/revenuecat/infrastructure/services/RevenueCatService.ts +132 -0
- package/src/revenuecat/infrastructure/services/ServiceStateManager.ts +71 -0
- package/src/revenuecat/infrastructure/utils/ApiKeyResolver.ts +38 -0
- package/src/revenuecat/infrastructure/utils/ExpirationDateCalculator.ts +20 -0
- package/src/revenuecat/infrastructure/utils/ExpoGoDetector.ts +27 -0
- package/src/revenuecat/infrastructure/utils/PremiumStatusSyncer.ts +88 -0
- package/src/revenuecat/presentation/hooks/useRevenueCat.ts +101 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.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";
|
|
@@ -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,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
|
+
|