@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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/domains/credits/infrastructure/operations/CreditsFetcher.ts +1 -2
  3. package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +1 -1
  4. package/src/domains/credits/presentation/deduct-credit/index.ts +2 -0
  5. package/src/domains/credits/presentation/deduct-credit/mutationConfig.ts +81 -0
  6. package/src/domains/credits/presentation/deduct-credit/types.ts +11 -0
  7. package/src/domains/credits/presentation/deduct-credit/useDeductCredit.ts +44 -0
  8. package/src/domains/subscription/application/initializer/BackgroundInitializer.ts +21 -0
  9. package/src/domains/subscription/application/initializer/ConfigValidator.ts +33 -0
  10. package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +45 -0
  11. package/src/domains/subscription/application/initializer/SubscriptionInitializer.ts +11 -0
  12. package/src/domains/subscription/application/initializer/index.ts +2 -0
  13. package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +13 -94
  14. package/src/domains/subscription/infrastructure/handlers/package-operations/PackageFetcher.ts +57 -0
  15. package/src/domains/subscription/infrastructure/handlers/package-operations/PackagePurchaser.ts +15 -0
  16. package/src/domains/subscription/infrastructure/handlers/package-operations/PackageRestorer.ts +34 -0
  17. package/src/domains/subscription/infrastructure/handlers/package-operations/PremiumStatusChecker.ts +9 -0
  18. package/src/domains/subscription/infrastructure/handlers/package-operations/index.ts +5 -0
  19. package/src/domains/subscription/infrastructure/handlers/package-operations/types.ts +4 -0
  20. package/src/domains/subscription/infrastructure/hooks/customer-info/index.ts +2 -0
  21. package/src/domains/subscription/infrastructure/hooks/customer-info/types.ts +9 -0
  22. package/src/domains/subscription/infrastructure/hooks/customer-info/useCustomerInfo.ts +57 -0
  23. package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +40 -128
  24. package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +9 -115
  25. package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +102 -0
  26. package/src/domains/subscription/infrastructure/services/listeners/ListenerState.ts +31 -0
  27. package/src/domains/subscription/infrastructure/services/purchase/PurchaseErrorHandler.ts +104 -0
  28. package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +70 -0
  29. package/src/domains/subscription/infrastructure/services/purchase/PurchaseValidator.ts +14 -0
  30. package/src/domains/subscription/infrastructure/utils/renewal/PackageTierComparator.ts +14 -0
  31. package/src/domains/subscription/infrastructure/utils/renewal/RenewalDetector.ts +78 -0
  32. package/src/domains/subscription/infrastructure/utils/renewal/RenewalStateUpdater.ts +11 -0
  33. package/src/domains/subscription/infrastructure/utils/renewal/index.ts +3 -0
  34. package/src/domains/subscription/infrastructure/utils/renewal/types.ts +14 -0
  35. package/src/domains/wallet/index.ts +2 -2
  36. package/src/domains/wallet/infrastructure/repositories/transaction/CollectionBuilder.ts +14 -0
  37. package/src/domains/wallet/infrastructure/repositories/transaction/TransactionFetcher.ts +46 -0
  38. package/src/domains/wallet/infrastructure/repositories/transaction/TransactionRepository.ts +34 -0
  39. package/src/domains/wallet/infrastructure/repositories/transaction/TransactionWriter.ts +43 -0
  40. package/src/domains/wallet/infrastructure/repositories/transaction/index.ts +10 -0
  41. package/src/domains/wallet/infrastructure/services/product-metadata/CacheManager.ts +30 -0
  42. package/src/domains/wallet/infrastructure/services/product-metadata/FirebaseFetcher.ts +17 -0
  43. package/src/domains/wallet/infrastructure/services/product-metadata/ProductMetadataService.ts +57 -0
  44. package/src/domains/wallet/infrastructure/services/product-metadata/ServiceManager.ts +29 -0
  45. package/src/domains/wallet/infrastructure/services/product-metadata/index.ts +7 -0
  46. package/src/domains/wallet/presentation/hooks/useProductMetadata.ts +1 -1
  47. package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +1 -1
  48. package/src/index.ts +2 -2
  49. package/src/init/createSubscriptionInitModule.ts +1 -1
  50. package/src/domains/credits/presentation/useDeductCredit.ts +0 -110
  51. package/src/domains/subscription/application/SubscriptionInitializer.ts +0 -112
  52. package/src/domains/subscription/infrastructure/hooks/useCustomerInfo.ts +0 -113
  53. package/src/domains/subscription/infrastructure/utils/RenewalDetector.ts +0 -141
  54. package/src/domains/wallet/infrastructure/repositories/TransactionRepository.ts +0 -114
  55. 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 { syncPremiumStatus } from "../utils/PremiumStatusSyncer";
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
- private listener: CustomerInfoUpdateListener | null = null;
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
- // Clean up old listener and reset state when user changes
30
- if (wasUserChange) {
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
- clearUserId(): void {
47
- this.currentUserId = null;
48
- this.renewalState = {
49
- previousExpirationDate: null,
50
- previousProductId: null,
51
- };
12
+ if (wasUserChange) {
13
+ this.removeListener();
14
+ this.state.resetRenewalState();
52
15
  }
53
16
 
54
- setupListener(config: RevenueCatConfig): void {
55
- this.removeListener();
17
+ this.state.currentUserId = userId;
56
18
 
57
- this.listener = async (customerInfo: CustomerInfo) => {
58
- if (!this.currentUserId) {
59
- return;
60
- }
19
+ if (wasUserChange || !this.state.listener) {
20
+ this.setupListener(config);
21
+ }
22
+ }
61
23
 
62
- const renewalResult = detectRenewal(
63
- this.renewalState,
64
- customerInfo,
65
- config.entitlementIdentifier
66
- );
24
+ clearUserId(): void {
25
+ this.state.currentUserId = null;
26
+ this.state.resetRenewalState();
27
+ }
67
28
 
68
- // Handle renewal (same product, extended expiration)
69
- if (renewalResult.isRenewal && config.onRenewalDetected) {
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
- // Handle plan change (upgrade/downgrade)
88
- if (renewalResult.isPlanChange && config.onPlanChanged) {
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
- this.renewalState = updateRenewalState(this.renewalState, renewalResult);
35
+ this.state.renewalState = await processCustomerInfo(
36
+ customerInfo,
37
+ this.state.currentUserId,
38
+ this.state.renewalState,
39
+ config
40
+ );
41
+ };
110
42
 
111
- // Only sync premium status if NOT a renewal or plan change
112
- // This prevents double credit initialization
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
- Purchases.addCustomerInfoUpdateListener(this.listener);
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
- removeListener(): void {
130
- if (this.listener) {
131
- Purchases.removeCustomerInfoUpdateListener(this.listener);
132
- this.listener = null;
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 Purchases, { type PurchasesPackage } from "react-native-purchases";
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
- isUserCancelledError,
11
- isNetworkError,
12
- isAlreadyPurchasedError,
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
- if (!deps.isInitialized()) throw new RevenueCatInitializationError();
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
- const { customerInfo } = await Purchases.purchasePackage(pkg);
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
- try {
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
- // Invalid credentials - configuration error
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
+ }