@umituz/react-native-subscription 2.32.1 → 2.33.1
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 +1 -1
- package/src/domains/credits/infrastructure/operations/CreditsFetcher.ts +1 -2
- package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +1 -1
- package/src/domains/credits/presentation/deduct-credit/index.ts +2 -0
- package/src/domains/credits/presentation/deduct-credit/mutationConfig.ts +81 -0
- package/src/domains/credits/presentation/deduct-credit/types.ts +11 -0
- package/src/domains/credits/presentation/deduct-credit/useDeductCredit.ts +44 -0
- package/src/domains/subscription/application/initializer/BackgroundInitializer.ts +21 -0
- package/src/domains/subscription/application/initializer/ConfigValidator.ts +33 -0
- package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +45 -0
- package/src/domains/subscription/application/initializer/SubscriptionInitializer.ts +11 -0
- package/src/domains/subscription/application/initializer/index.ts +2 -0
- package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +13 -94
- package/src/domains/subscription/infrastructure/handlers/package-operations/PackageFetcher.ts +57 -0
- package/src/domains/subscription/infrastructure/handlers/package-operations/PackagePurchaser.ts +15 -0
- package/src/domains/subscription/infrastructure/handlers/package-operations/PackageRestorer.ts +34 -0
- package/src/domains/subscription/infrastructure/handlers/package-operations/PremiumStatusChecker.ts +9 -0
- package/src/domains/subscription/infrastructure/handlers/package-operations/index.ts +5 -0
- package/src/domains/subscription/infrastructure/handlers/package-operations/types.ts +4 -0
- package/src/domains/subscription/infrastructure/hooks/customer-info/index.ts +2 -0
- package/src/domains/subscription/infrastructure/hooks/customer-info/types.ts +9 -0
- package/src/domains/subscription/infrastructure/hooks/customer-info/useCustomerInfo.ts +57 -0
- package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +40 -128
- package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +9 -115
- package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +102 -0
- package/src/domains/subscription/infrastructure/services/listeners/ListenerState.ts +31 -0
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseErrorHandler.ts +104 -0
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +70 -0
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseValidator.ts +14 -0
- package/src/domains/subscription/infrastructure/utils/renewal/PackageTierComparator.ts +14 -0
- package/src/domains/subscription/infrastructure/utils/renewal/RenewalDetector.ts +78 -0
- package/src/domains/subscription/infrastructure/utils/renewal/RenewalStateUpdater.ts +11 -0
- package/src/domains/subscription/infrastructure/utils/renewal/index.ts +3 -0
- package/src/domains/subscription/infrastructure/utils/renewal/types.ts +14 -0
- package/src/domains/wallet/index.ts +2 -2
- package/src/domains/wallet/infrastructure/repositories/transaction/CollectionBuilder.ts +14 -0
- package/src/domains/wallet/infrastructure/repositories/transaction/TransactionFetcher.ts +46 -0
- package/src/domains/wallet/infrastructure/repositories/transaction/TransactionRepository.ts +34 -0
- package/src/domains/wallet/infrastructure/repositories/transaction/TransactionWriter.ts +43 -0
- package/src/domains/wallet/infrastructure/repositories/transaction/index.ts +10 -0
- package/src/domains/wallet/infrastructure/services/product-metadata/CacheManager.ts +30 -0
- package/src/domains/wallet/infrastructure/services/product-metadata/FirebaseFetcher.ts +17 -0
- package/src/domains/wallet/infrastructure/services/product-metadata/ProductMetadataService.ts +57 -0
- package/src/domains/wallet/infrastructure/services/product-metadata/ServiceManager.ts +29 -0
- package/src/domains/wallet/infrastructure/services/product-metadata/index.ts +7 -0
- package/src/domains/wallet/presentation/hooks/useProductMetadata.ts +1 -1
- package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +1 -1
- package/src/index.ts +2 -2
- package/src/init/createSubscriptionInitModule.ts +1 -1
- package/src/domains/credits/presentation/useDeductCredit.ts +0 -110
- package/src/domains/subscription/application/SubscriptionInitializer.ts +0 -112
- package/src/domains/subscription/infrastructure/hooks/useCustomerInfo.ts +0 -113
- package/src/domains/subscription/infrastructure/utils/RenewalDetector.ts +0 -141
- package/src/domains/wallet/infrastructure/repositories/TransactionRepository.ts +0 -114
- package/src/domains/wallet/infrastructure/services/ProductMetadataService.ts +0 -114
|
@@ -1,145 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
* Customer Info Listener Manager
|
|
3
|
-
* Handles RevenueCat customer info update listeners with renewal detection
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import Purchases, {
|
|
7
|
-
type CustomerInfo,
|
|
8
|
-
type CustomerInfoUpdateListener,
|
|
9
|
-
} from "react-native-purchases";
|
|
1
|
+
import Purchases, { type CustomerInfo } from "react-native-purchases";
|
|
10
2
|
import type { RevenueCatConfig } from "../../../revenuecat/core/types";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
detectRenewal,
|
|
14
|
-
updateRenewalState,
|
|
15
|
-
type RenewalState,
|
|
16
|
-
} from "../utils/RenewalDetector";
|
|
3
|
+
import { ListenerState } from "./listeners/ListenerState";
|
|
4
|
+
import { processCustomerInfo } from "./listeners/CustomerInfoHandler";
|
|
17
5
|
|
|
18
6
|
export class CustomerInfoListenerManager {
|
|
19
|
-
|
|
20
|
-
private currentUserId: string | null = null;
|
|
21
|
-
private renewalState: RenewalState = {
|
|
22
|
-
previousExpirationDate: null,
|
|
23
|
-
previousProductId: null,
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
setUserId(userId: string, config: RevenueCatConfig): void {
|
|
27
|
-
const wasUserChange = this.currentUserId && this.currentUserId !== userId;
|
|
7
|
+
private state = new ListenerState();
|
|
28
8
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this.removeListener();
|
|
32
|
-
this.renewalState = {
|
|
33
|
-
previousExpirationDate: null,
|
|
34
|
-
previousProductId: null,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
this.currentUserId = userId;
|
|
39
|
-
|
|
40
|
-
// Setup new listener for new user or if no listener exists
|
|
41
|
-
if (wasUserChange || !this.listener) {
|
|
42
|
-
this.setupListener(config);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
9
|
+
setUserId(userId: string, config: RevenueCatConfig): void {
|
|
10
|
+
const wasUserChange = this.state.hasUserChanged(userId);
|
|
45
11
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
previousExpirationDate: null,
|
|
50
|
-
previousProductId: null,
|
|
51
|
-
};
|
|
12
|
+
if (wasUserChange) {
|
|
13
|
+
this.removeListener();
|
|
14
|
+
this.state.resetRenewalState();
|
|
52
15
|
}
|
|
53
16
|
|
|
54
|
-
|
|
55
|
-
this.removeListener();
|
|
17
|
+
this.state.currentUserId = userId;
|
|
56
18
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
19
|
+
if (wasUserChange || !this.state.listener) {
|
|
20
|
+
this.setupListener(config);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
61
23
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
);
|
|
24
|
+
clearUserId(): void {
|
|
25
|
+
this.state.currentUserId = null;
|
|
26
|
+
this.state.resetRenewalState();
|
|
27
|
+
}
|
|
67
28
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
await config.onRenewalDetected(
|
|
72
|
-
this.currentUserId,
|
|
73
|
-
renewalResult.productId!,
|
|
74
|
-
renewalResult.newExpirationDate!,
|
|
75
|
-
customerInfo
|
|
76
|
-
);
|
|
77
|
-
} catch (error) {
|
|
78
|
-
console.error('[CustomerInfoListenerManager] Renewal detection callback failed', {
|
|
79
|
-
userId: this.currentUserId,
|
|
80
|
-
productId: renewalResult.productId,
|
|
81
|
-
error
|
|
82
|
-
});
|
|
83
|
-
// Swallow error to prevent listener crash
|
|
84
|
-
}
|
|
85
|
-
}
|
|
29
|
+
setupListener(config: RevenueCatConfig): void {
|
|
30
|
+
this.removeListener();
|
|
86
31
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
await config.onPlanChanged(
|
|
91
|
-
this.currentUserId,
|
|
92
|
-
renewalResult.productId!,
|
|
93
|
-
renewalResult.previousProductId!,
|
|
94
|
-
renewalResult.isUpgrade,
|
|
95
|
-
customerInfo
|
|
96
|
-
);
|
|
97
|
-
} catch (error) {
|
|
98
|
-
console.error('[CustomerInfoListenerManager] Plan change callback failed', {
|
|
99
|
-
userId: this.currentUserId,
|
|
100
|
-
productId: renewalResult.productId,
|
|
101
|
-
previousProductId: renewalResult.previousProductId,
|
|
102
|
-
isUpgrade: renewalResult.isUpgrade,
|
|
103
|
-
error
|
|
104
|
-
});
|
|
105
|
-
// Swallow error to prevent listener crash
|
|
106
|
-
}
|
|
107
|
-
}
|
|
32
|
+
this.state.listener = async (customerInfo: CustomerInfo) => {
|
|
33
|
+
if (!this.state.currentUserId) return;
|
|
108
34
|
|
|
109
|
-
|
|
35
|
+
this.state.renewalState = await processCustomerInfo(
|
|
36
|
+
customerInfo,
|
|
37
|
+
this.state.currentUserId,
|
|
38
|
+
this.state.renewalState,
|
|
39
|
+
config
|
|
40
|
+
);
|
|
41
|
+
};
|
|
110
42
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!renewalResult.isRenewal && !renewalResult.isPlanChange) {
|
|
114
|
-
try {
|
|
115
|
-
await syncPremiumStatus(config, this.currentUserId, customerInfo);
|
|
116
|
-
} catch (error) {
|
|
117
|
-
console.error('[CustomerInfoListenerManager] Premium status sync failed', {
|
|
118
|
-
userId: this.currentUserId,
|
|
119
|
-
error
|
|
120
|
-
});
|
|
121
|
-
// Swallow error to prevent listener crash
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
};
|
|
43
|
+
Purchases.addCustomerInfoUpdateListener(this.state.listener);
|
|
44
|
+
}
|
|
125
45
|
|
|
126
|
-
|
|
46
|
+
removeListener(): void {
|
|
47
|
+
if (this.state.listener) {
|
|
48
|
+
Purchases.removeCustomerInfoUpdateListener(this.state.listener);
|
|
49
|
+
this.state.listener = null;
|
|
127
50
|
}
|
|
51
|
+
}
|
|
128
52
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
destroy(): void {
|
|
137
|
-
this.removeListener();
|
|
138
|
-
this.clearUserId();
|
|
139
|
-
// Reset renewal state to ensure clean state
|
|
140
|
-
this.renewalState = {
|
|
141
|
-
previousExpirationDate: null,
|
|
142
|
-
previousProductId: null,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
53
|
+
destroy(): void {
|
|
54
|
+
this.removeListener();
|
|
55
|
+
this.state.reset();
|
|
56
|
+
}
|
|
145
57
|
}
|
|
@@ -1,143 +1,37 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
2
|
import type { PurchaseResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
3
|
-
import {
|
|
4
|
-
RevenueCatPurchaseError,
|
|
5
|
-
RevenueCatInitializationError,
|
|
6
|
-
RevenueCatNetworkError,
|
|
7
|
-
} from "../../../revenuecat/core/errors";
|
|
8
3
|
import type { RevenueCatConfig } from "../../../revenuecat/core/types";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
isInvalidCredentialsError,
|
|
14
|
-
getRawErrorMessage,
|
|
15
|
-
getErrorCode,
|
|
16
|
-
} from "../../../revenuecat/core/types";
|
|
17
|
-
import { syncPremiumStatus, notifyPurchaseCompleted } from "../utils/PremiumStatusSyncer";
|
|
18
|
-
import { getSavedPurchase, clearSavedPurchase } from "../../presentation/useAuthAwarePurchase";
|
|
19
|
-
import { handleRestore } from "./RestoreHandler";
|
|
4
|
+
import { isUserCancelledError, isAlreadyPurchasedError } from "../../../revenuecat/core/types";
|
|
5
|
+
import { validatePurchaseReady, isConsumableProduct } from "./purchase/PurchaseValidator";
|
|
6
|
+
import { executePurchase } from "./purchase/PurchaseExecutor";
|
|
7
|
+
import { handleAlreadyPurchasedError, handlePurchaseError } from "./purchase/PurchaseErrorHandler";
|
|
20
8
|
|
|
21
9
|
export interface PurchaseHandlerDeps {
|
|
22
10
|
config: RevenueCatConfig;
|
|
23
11
|
isInitialized: () => boolean;
|
|
24
12
|
}
|
|
25
13
|
|
|
26
|
-
function isConsumableProduct(pkg: PurchasesPackage, consumableIds: string[]): boolean {
|
|
27
|
-
if (consumableIds.length === 0) return false;
|
|
28
|
-
const identifier = pkg.product.identifier.toLowerCase();
|
|
29
|
-
return consumableIds.some((id) => identifier.includes(id.toLowerCase()));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
14
|
export async function handlePurchase(
|
|
33
15
|
deps: PurchaseHandlerDeps,
|
|
34
16
|
pkg: PurchasesPackage,
|
|
35
17
|
userId: string
|
|
36
18
|
): Promise<PurchaseResult> {
|
|
37
|
-
|
|
19
|
+
validatePurchaseReady(deps.isInitialized());
|
|
38
20
|
|
|
39
21
|
const consumableIds = deps.config.consumableProductIdentifiers || [];
|
|
40
22
|
const isConsumable = isConsumableProduct(pkg, consumableIds);
|
|
41
|
-
const entitlementIdentifier = deps.config.entitlementIdentifier;
|
|
42
23
|
|
|
43
24
|
try {
|
|
44
|
-
|
|
45
|
-
const savedPurchase = getSavedPurchase();
|
|
46
|
-
const source = savedPurchase?.source;
|
|
47
|
-
|
|
48
|
-
if (isConsumable) {
|
|
49
|
-
await notifyPurchaseCompleted(deps.config, userId, pkg.product.identifier, customerInfo, source);
|
|
50
|
-
clearSavedPurchase();
|
|
51
|
-
return { success: true, isPremium: false, customerInfo, isConsumable: true, productId: pkg.product.identifier };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const isPremium = !!customerInfo.entitlements.active[entitlementIdentifier];
|
|
55
|
-
|
|
56
|
-
if (isPremium) {
|
|
57
|
-
await syncPremiumStatus(deps.config, userId, customerInfo);
|
|
58
|
-
await notifyPurchaseCompleted(deps.config, userId, pkg.product.identifier, customerInfo, source);
|
|
59
|
-
clearSavedPurchase();
|
|
60
|
-
return { success: true, isPremium: true, customerInfo, productId: pkg.product.identifier };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Purchase completed but no entitlement - still notify (test store scenario)
|
|
64
|
-
await notifyPurchaseCompleted(deps.config, userId, pkg.product.identifier, customerInfo, source);
|
|
65
|
-
clearSavedPurchase();
|
|
66
|
-
return { success: true, isPremium: false, customerInfo, productId: pkg.product.identifier };
|
|
25
|
+
return await executePurchase(deps.config, userId, pkg, isConsumable);
|
|
67
26
|
} catch (error) {
|
|
68
|
-
// User cancelled - not an error, just return false
|
|
69
27
|
if (isUserCancelledError(error)) {
|
|
70
28
|
return { success: false, isPremium: false, productId: pkg.product.identifier };
|
|
71
29
|
}
|
|
72
30
|
|
|
73
|
-
// Already purchased - auto-restore (RevenueCat best practice)
|
|
74
31
|
if (isAlreadyPurchasedError(error)) {
|
|
75
|
-
|
|
76
|
-
const restoreResult = await handleRestore(deps, userId);
|
|
77
|
-
if (restoreResult.success && restoreResult.isPremium) {
|
|
78
|
-
// Restore succeeded, notify and return success
|
|
79
|
-
if (restoreResult.customerInfo) {
|
|
80
|
-
await notifyPurchaseCompleted(deps.config, userId, pkg.product.identifier, restoreResult.customerInfo, getSavedPurchase()?.source);
|
|
81
|
-
}
|
|
82
|
-
clearSavedPurchase();
|
|
83
|
-
return {
|
|
84
|
-
success: true,
|
|
85
|
-
isPremium: true,
|
|
86
|
-
customerInfo: restoreResult.customerInfo,
|
|
87
|
-
productId: restoreResult.productId || pkg.product.identifier,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
} catch (_restoreError) {
|
|
91
|
-
// Restore failed, throw original error
|
|
92
|
-
throw new RevenueCatPurchaseError(
|
|
93
|
-
"You already own this subscription, but restore failed. Please try restoring purchases manually.",
|
|
94
|
-
pkg.product.identifier,
|
|
95
|
-
error instanceof Error ? error : undefined
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
// Restore succeeded but no premium - throw original error
|
|
99
|
-
throw new RevenueCatPurchaseError(
|
|
100
|
-
"You already own this subscription, but it could not be activated.",
|
|
101
|
-
pkg.product.identifier,
|
|
102
|
-
error instanceof Error ? error : undefined
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Network error - throw specific error type
|
|
107
|
-
if (isNetworkError(error)) {
|
|
108
|
-
throw new RevenueCatNetworkError(
|
|
109
|
-
"Network error during purchase. Please check your internet connection and try again.",
|
|
110
|
-
error instanceof Error ? error : undefined
|
|
111
|
-
);
|
|
32
|
+
return await handleAlreadyPurchasedError(deps, userId, pkg, error);
|
|
112
33
|
}
|
|
113
34
|
|
|
114
|
-
|
|
115
|
-
if (isInvalidCredentialsError(error)) {
|
|
116
|
-
throw new RevenueCatPurchaseError(
|
|
117
|
-
"App configuration error. Please contact support.",
|
|
118
|
-
pkg.product.identifier,
|
|
119
|
-
error instanceof Error ? error : undefined
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Generic error with code
|
|
124
|
-
const errorCode = getErrorCode(error);
|
|
125
|
-
const errorMessage = getRawErrorMessage(error, "Purchase failed");
|
|
126
|
-
const enhancedMessage = errorCode
|
|
127
|
-
? `${errorMessage} (Code: ${errorCode})`
|
|
128
|
-
: errorMessage;
|
|
129
|
-
|
|
130
|
-
console.error('[PurchaseHandler] Purchase failed', {
|
|
131
|
-
productId: pkg.product.identifier,
|
|
132
|
-
userId,
|
|
133
|
-
errorCode,
|
|
134
|
-
error,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
throw new RevenueCatPurchaseError(
|
|
138
|
-
enhancedMessage,
|
|
139
|
-
pkg.product.identifier,
|
|
140
|
-
error instanceof Error ? error : undefined
|
|
141
|
-
);
|
|
35
|
+
return handlePurchaseError(error, pkg, userId);
|
|
142
36
|
}
|
|
143
37
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
+
import type { RevenueCatConfig } from "../../../../revenuecat/core/types";
|
|
3
|
+
import { syncPremiumStatus } from "../../utils/PremiumStatusSyncer";
|
|
4
|
+
import { detectRenewal, updateRenewalState, type RenewalState } from "../../utils/renewal";
|
|
5
|
+
|
|
6
|
+
async function handleRenewal(
|
|
7
|
+
userId: string,
|
|
8
|
+
productId: string,
|
|
9
|
+
expirationDate: string,
|
|
10
|
+
customerInfo: CustomerInfo,
|
|
11
|
+
onRenewalDetected?: RevenueCatConfig['onRenewalDetected']
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
if (!onRenewalDetected) return;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await onRenewalDetected(userId, productId, expirationDate, customerInfo);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error('[CustomerInfoHandler] Renewal detection callback failed', {
|
|
19
|
+
userId,
|
|
20
|
+
productId,
|
|
21
|
+
error
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function handlePlanChange(
|
|
27
|
+
userId: string,
|
|
28
|
+
newProductId: string,
|
|
29
|
+
previousProductId: string,
|
|
30
|
+
isUpgrade: boolean,
|
|
31
|
+
customerInfo: CustomerInfo,
|
|
32
|
+
onPlanChanged?: RevenueCatConfig['onPlanChanged']
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
if (!onPlanChanged) return;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await onPlanChanged(userId, newProductId, previousProductId, isUpgrade, customerInfo);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('[CustomerInfoHandler] Plan change callback failed', {
|
|
40
|
+
userId,
|
|
41
|
+
newProductId,
|
|
42
|
+
previousProductId,
|
|
43
|
+
isUpgrade,
|
|
44
|
+
error
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function handlePremiumStatusSync(
|
|
50
|
+
config: RevenueCatConfig,
|
|
51
|
+
userId: string,
|
|
52
|
+
customerInfo: CustomerInfo
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
await syncPremiumStatus(config, userId, customerInfo);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('[CustomerInfoHandler] Premium status sync failed', {
|
|
58
|
+
userId,
|
|
59
|
+
error
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function processCustomerInfo(
|
|
65
|
+
customerInfo: CustomerInfo,
|
|
66
|
+
userId: string,
|
|
67
|
+
renewalState: RenewalState,
|
|
68
|
+
config: RevenueCatConfig
|
|
69
|
+
): Promise<RenewalState> {
|
|
70
|
+
const renewalResult = detectRenewal(
|
|
71
|
+
renewalState,
|
|
72
|
+
customerInfo,
|
|
73
|
+
config.entitlementIdentifier
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (renewalResult.isRenewal) {
|
|
77
|
+
await handleRenewal(
|
|
78
|
+
userId,
|
|
79
|
+
renewalResult.productId!,
|
|
80
|
+
renewalResult.newExpirationDate!,
|
|
81
|
+
customerInfo,
|
|
82
|
+
config.onRenewalDetected
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (renewalResult.isPlanChange) {
|
|
87
|
+
await handlePlanChange(
|
|
88
|
+
userId,
|
|
89
|
+
renewalResult.productId!,
|
|
90
|
+
renewalResult.previousProductId!,
|
|
91
|
+
renewalResult.isUpgrade,
|
|
92
|
+
customerInfo,
|
|
93
|
+
config.onPlanChanged
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!renewalResult.isRenewal && !renewalResult.isPlanChange) {
|
|
98
|
+
await handlePremiumStatusSync(config, userId, customerInfo);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return updateRenewalState(renewalState, renewalResult);
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CustomerInfoUpdateListener } from "react-native-purchases";
|
|
2
|
+
import type { RenewalState } from "../../utils/renewal";
|
|
3
|
+
|
|
4
|
+
export class ListenerState {
|
|
5
|
+
listener: CustomerInfoUpdateListener | null = null;
|
|
6
|
+
currentUserId: string | null = null;
|
|
7
|
+
renewalState: RenewalState = {
|
|
8
|
+
previousExpirationDate: null,
|
|
9
|
+
previousProductId: null,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
reset(): void {
|
|
13
|
+
this.listener = null;
|
|
14
|
+
this.currentUserId = null;
|
|
15
|
+
this.renewalState = {
|
|
16
|
+
previousExpirationDate: null,
|
|
17
|
+
previousProductId: null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
resetRenewalState(): void {
|
|
22
|
+
this.renewalState = {
|
|
23
|
+
previousExpirationDate: null,
|
|
24
|
+
previousProductId: null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
hasUserChanged(newUserId: string): boolean {
|
|
29
|
+
return !!(this.currentUserId && this.currentUserId !== newUserId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
|
+
import type { PurchaseResult } from "../../../../../shared/application/ports/IRevenueCatService";
|
|
3
|
+
import {
|
|
4
|
+
RevenueCatPurchaseError,
|
|
5
|
+
RevenueCatNetworkError,
|
|
6
|
+
} from "../../../../revenuecat/core/errors";
|
|
7
|
+
import {
|
|
8
|
+
isUserCancelledError,
|
|
9
|
+
isNetworkError,
|
|
10
|
+
isInvalidCredentialsError,
|
|
11
|
+
getRawErrorMessage,
|
|
12
|
+
getErrorCode,
|
|
13
|
+
} from "../../../../revenuecat/core/types";
|
|
14
|
+
import { getSavedPurchase, clearSavedPurchase } from "../../../presentation/useAuthAwarePurchase";
|
|
15
|
+
import { notifyPurchaseCompleted } from "../../utils/PremiumStatusSyncer";
|
|
16
|
+
import { handleRestore } from "../RestoreHandler";
|
|
17
|
+
import type { PurchaseHandlerDeps } from "../PurchaseHandler";
|
|
18
|
+
|
|
19
|
+
export async function handleAlreadyPurchasedError(
|
|
20
|
+
deps: PurchaseHandlerDeps,
|
|
21
|
+
userId: string,
|
|
22
|
+
pkg: PurchasesPackage,
|
|
23
|
+
error: unknown
|
|
24
|
+
): Promise<PurchaseResult> {
|
|
25
|
+
try {
|
|
26
|
+
const restoreResult = await handleRestore(deps, userId);
|
|
27
|
+
if (restoreResult.success && restoreResult.isPremium && restoreResult.customerInfo) {
|
|
28
|
+
await notifyPurchaseCompleted(
|
|
29
|
+
deps.config,
|
|
30
|
+
userId,
|
|
31
|
+
pkg.product.identifier,
|
|
32
|
+
restoreResult.customerInfo,
|
|
33
|
+
getSavedPurchase()?.source
|
|
34
|
+
);
|
|
35
|
+
clearSavedPurchase();
|
|
36
|
+
return {
|
|
37
|
+
success: true,
|
|
38
|
+
isPremium: true,
|
|
39
|
+
customerInfo: restoreResult.customerInfo,
|
|
40
|
+
productId: restoreResult.productId || pkg.product.identifier,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
} catch (_restoreError) {
|
|
44
|
+
throw new RevenueCatPurchaseError(
|
|
45
|
+
"You already own this subscription, but restore failed. Please try restoring purchases manually.",
|
|
46
|
+
pkg.product.identifier,
|
|
47
|
+
error instanceof Error ? error : undefined
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new RevenueCatPurchaseError(
|
|
52
|
+
"You already own this subscription, but it could not be activated.",
|
|
53
|
+
pkg.product.identifier,
|
|
54
|
+
error instanceof Error ? error : undefined
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function handlePurchaseError(
|
|
59
|
+
error: unknown,
|
|
60
|
+
pkg: PurchasesPackage,
|
|
61
|
+
userId: string
|
|
62
|
+
): never {
|
|
63
|
+
if (isUserCancelledError(error)) {
|
|
64
|
+
throw new RevenueCatPurchaseError(
|
|
65
|
+
"Purchase cancelled",
|
|
66
|
+
pkg.product.identifier,
|
|
67
|
+
error instanceof Error ? error : undefined
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (isNetworkError(error)) {
|
|
72
|
+
throw new RevenueCatNetworkError(
|
|
73
|
+
"Network error during purchase. Please check your internet connection and try again.",
|
|
74
|
+
error instanceof Error ? error : undefined
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (isInvalidCredentialsError(error)) {
|
|
79
|
+
throw new RevenueCatPurchaseError(
|
|
80
|
+
"App configuration error. Please contact support.",
|
|
81
|
+
pkg.product.identifier,
|
|
82
|
+
error instanceof Error ? error : undefined
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const errorCode = getErrorCode(error);
|
|
87
|
+
const errorMessage = getRawErrorMessage(error, "Purchase failed");
|
|
88
|
+
const enhancedMessage = errorCode
|
|
89
|
+
? `${errorMessage} (Code: ${errorCode})`
|
|
90
|
+
: errorMessage;
|
|
91
|
+
|
|
92
|
+
console.error('[PurchaseHandler] Purchase failed', {
|
|
93
|
+
productId: pkg.product.identifier,
|
|
94
|
+
userId,
|
|
95
|
+
errorCode,
|
|
96
|
+
error,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
throw new RevenueCatPurchaseError(
|
|
100
|
+
enhancedMessage,
|
|
101
|
+
pkg.product.identifier,
|
|
102
|
+
error instanceof Error ? error : undefined
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Purchases, { type PurchasesPackage, type CustomerInfo } from "react-native-purchases";
|
|
2
|
+
import type { PurchaseResult } from "../../../../../shared/application/ports/IRevenueCatService";
|
|
3
|
+
import type { RevenueCatConfig } from "../../../../revenuecat/core/types";
|
|
4
|
+
import { syncPremiumStatus, notifyPurchaseCompleted } from "../../utils/PremiumStatusSyncer";
|
|
5
|
+
import { getSavedPurchase, clearSavedPurchase } from "../../../presentation/useAuthAwarePurchase";
|
|
6
|
+
|
|
7
|
+
async function executeConsumablePurchase(
|
|
8
|
+
config: RevenueCatConfig,
|
|
9
|
+
userId: string,
|
|
10
|
+
productId: string,
|
|
11
|
+
customerInfo: CustomerInfo
|
|
12
|
+
): Promise<PurchaseResult> {
|
|
13
|
+
const source = getSavedPurchase()?.source;
|
|
14
|
+
await notifyPurchaseCompleted(config, userId, productId, customerInfo, source);
|
|
15
|
+
clearSavedPurchase();
|
|
16
|
+
return {
|
|
17
|
+
success: true,
|
|
18
|
+
isPremium: false,
|
|
19
|
+
customerInfo,
|
|
20
|
+
isConsumable: true,
|
|
21
|
+
productId,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function executeSubscriptionPurchase(
|
|
26
|
+
config: RevenueCatConfig,
|
|
27
|
+
userId: string,
|
|
28
|
+
productId: string,
|
|
29
|
+
customerInfo: CustomerInfo,
|
|
30
|
+
entitlementIdentifier: string
|
|
31
|
+
): Promise<PurchaseResult> {
|
|
32
|
+
const isPremium = !!customerInfo.entitlements.active[entitlementIdentifier];
|
|
33
|
+
const source = getSavedPurchase()?.source;
|
|
34
|
+
|
|
35
|
+
if (isPremium) {
|
|
36
|
+
await syncPremiumStatus(config, userId, customerInfo);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await notifyPurchaseCompleted(config, userId, productId, customerInfo, source);
|
|
40
|
+
clearSavedPurchase();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
isPremium,
|
|
45
|
+
customerInfo,
|
|
46
|
+
productId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function executePurchase(
|
|
51
|
+
config: RevenueCatConfig,
|
|
52
|
+
userId: string,
|
|
53
|
+
pkg: PurchasesPackage,
|
|
54
|
+
isConsumable: boolean
|
|
55
|
+
): Promise<PurchaseResult> {
|
|
56
|
+
const { customerInfo } = await Purchases.purchasePackage(pkg);
|
|
57
|
+
const productId = pkg.product.identifier;
|
|
58
|
+
|
|
59
|
+
if (isConsumable) {
|
|
60
|
+
return executeConsumablePurchase(config, userId, productId, customerInfo);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return executeSubscriptionPurchase(
|
|
64
|
+
config,
|
|
65
|
+
userId,
|
|
66
|
+
productId,
|
|
67
|
+
customerInfo,
|
|
68
|
+
config.entitlementIdentifier
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
|
+
import { RevenueCatInitializationError } from "../../../../revenuecat/core/errors";
|
|
3
|
+
|
|
4
|
+
export function validatePurchaseReady(isInitialized: boolean): void {
|
|
5
|
+
if (!isInitialized) {
|
|
6
|
+
throw new RevenueCatInitializationError();
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isConsumableProduct(pkg: PurchasesPackage, consumableIds: string[]): boolean {
|
|
11
|
+
if (consumableIds.length === 0) return false;
|
|
12
|
+
const identifier = pkg.product.identifier.toLowerCase();
|
|
13
|
+
return consumableIds.some((id) => identifier.includes(id.toLowerCase()));
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { detectPackageType } from "../../../../../utils/packageTypeDetector";
|
|
2
|
+
|
|
3
|
+
const PACKAGE_TIER_ORDER: Record<string, number> = {
|
|
4
|
+
weekly: 1,
|
|
5
|
+
monthly: 2,
|
|
6
|
+
yearly: 3,
|
|
7
|
+
unknown: 0,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function getPackageTier(productId: string | null): number {
|
|
11
|
+
if (!productId) return 0;
|
|
12
|
+
const packageType = detectPackageType(productId);
|
|
13
|
+
return PACKAGE_TIER_ORDER[packageType] ?? 0;
|
|
14
|
+
}
|