@umituz/react-native-subscription 2.32.0 → 2.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
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",
@@ -1,20 +1,15 @@
1
- import { getDoc, setDoc } from "firebase/firestore";
2
- import { BaseRepository, serverTimestamp, type Firestore, type DocumentReference } from "@umituz/react-native-firebase";
1
+ import type { Firestore, DocumentReference } from "@umituz/react-native-firebase";
2
+ import { BaseRepository } from "@umituz/react-native-firebase";
3
3
  import type { CreditsConfig, CreditsResult, DeductCreditsResult } from "../core/Credits";
4
- import type { UserCreditsDocumentRead, PurchaseSource } from "../core/UserCreditsDocument";
5
- import { initializeCreditsTransaction } from "../application/CreditsInitializer";
6
- import { mapCreditsDocumentToEntity } from "../core/CreditsMapper";
4
+ import type { PurchaseSource } from "../core/UserCreditsDocument";
7
5
  import type { RevenueCatData } from "../../revenuecat/core/types";
8
6
  import { deductCreditsOperation } from "../application/DeductCreditsCommand";
9
- import { calculateCreditLimit } from "../application/CreditLimitCalculator";
10
7
  import { PURCHASE_TYPE, type PurchaseType } from "../../subscription/core/SubscriptionConstants";
11
8
  import { requireFirestore, buildDocRef, type CollectionConfig } from "../../../shared/infrastructure/firestore";
12
- import { SUBSCRIPTION_STATUS } from "../../subscription/core/SubscriptionConstants";
9
+ import { fetchCredits, checkHasCredits } from "./operations/CreditsFetcher";
10
+ import { syncExpiredStatus } from "./operations/CreditsWriter";
11
+ import { initializeCreditsWithRetry } from "./operations/CreditsInitializer";
13
12
 
14
- /**
15
- * Credits Repository
16
- * Provides domain-specific database operations for credits system.
17
- */
18
13
  export class CreditsRepository extends BaseRepository {
19
14
  constructor(private config: CreditsConfig) {
20
15
  super(config.collectionName);
@@ -34,14 +29,7 @@ export class CreditsRepository extends BaseRepository {
34
29
 
35
30
  async getCredits(userId: string): Promise<CreditsResult> {
36
31
  const db = requireFirestore();
37
- const snap = await getDoc(this.getRef(db, userId));
38
-
39
- if (!snap.exists()) {
40
- return { success: true, data: null, error: null };
41
- }
42
-
43
- const entity = mapCreditsDocumentToEntity(snap.data() as UserCreditsDocumentRead);
44
- return { success: true, data: entity, error: null };
32
+ return fetchCredits(this.getRef(db, userId));
45
33
  }
46
34
 
47
35
  async initializeCredits(
@@ -53,101 +41,32 @@ export class CreditsRepository extends BaseRepository {
53
41
  type: PurchaseType = PURCHASE_TYPE.INITIAL
54
42
  ): Promise<CreditsResult> {
55
43
  const db = requireFirestore();
56
- const creditLimit = calculateCreditLimit(productId, this.config);
57
- const cfg = { ...this.config, creditLimit };
58
-
59
- const maxRetries = 3;
60
- let lastError: any;
61
-
62
- for (let attempt = 0; attempt < maxRetries; attempt++) {
63
- try {
64
- const result = await initializeCreditsTransaction(
65
- db,
66
- this.getRef(db, userId),
67
- cfg,
68
- purchaseId,
69
- {
70
- productId,
71
- source,
72
- expirationDate: revenueCatData.expirationDate,
73
- willRenew: revenueCatData.willRenew,
74
- originalTransactionId: revenueCatData.originalTransactionId,
75
- isPremium: revenueCatData.isPremium,
76
- periodType: revenueCatData.periodType,
77
- unsubscribeDetectedAt: revenueCatData.unsubscribeDetectedAt,
78
- billingIssueDetectedAt: revenueCatData.billingIssueDetectedAt,
79
- store: revenueCatData.store,
80
- ownershipType: revenueCatData.ownershipType,
81
- type,
82
- }
83
- );
84
-
85
- return {
86
- success: true,
87
- data: result.finalData ? mapCreditsDocumentToEntity(result.finalData) : null,
88
- error: null,
89
- };
90
- } catch (error: any) {
91
- lastError = error;
92
-
93
- const isTransientError =
94
- error?.code === 'already-exists' ||
95
- error?.code === 'DEADLINE_EXCEEDED' ||
96
- error?.code === 'UNAVAILABLE' ||
97
- error?.code === 'RESOURCE_EXHAUSTED' ||
98
- error?.message?.includes('already-exists') ||
99
- error?.message?.includes('timeout') ||
100
- error?.message?.includes('unavailable');
101
-
102
- if (isTransientError && attempt < maxRetries - 1) {
103
- await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
104
- continue;
105
- }
106
- break;
107
- }
108
- }
109
-
110
- const errorMessage = lastError instanceof Error
111
- ? lastError.message
112
- : typeof lastError === 'string'
113
- ? lastError
114
- : 'Unknown error during credit initialization';
115
-
116
- const errorCode = lastError?.code ?? 'UNKNOWN_ERROR';
117
-
118
- return {
119
- success: false,
120
- data: null,
121
- error: {
122
- message: errorMessage,
123
- code: errorCode,
124
- },
125
- };
44
+ return initializeCreditsWithRetry({
45
+ db,
46
+ ref: this.getRef(db, userId),
47
+ config: this.config,
48
+ userId,
49
+ purchaseId,
50
+ productId,
51
+ source,
52
+ revenueCatData,
53
+ type,
54
+ });
126
55
  }
127
56
 
128
- /**
129
- * Deducts credits using atomic transaction logic.
130
- */
131
57
  async deductCredit(userId: string, cost: number): Promise<DeductCreditsResult> {
132
58
  const db = requireFirestore();
133
59
  return deductCreditsOperation(db, this.getRef(db, userId), cost, userId);
134
60
  }
135
61
 
136
62
  async hasCredits(userId: string, cost: number): Promise<boolean> {
137
- const result = await this.getCredits(userId);
138
- if (!result.success || !result.data) return false;
139
- return result.data.credits >= cost;
63
+ const db = requireFirestore();
64
+ return checkHasCredits(this.getRef(db, userId), cost);
140
65
  }
141
66
 
142
67
  async syncExpiredStatus(userId: string): Promise<void> {
143
68
  const db = requireFirestore();
144
- const ref = this.getRef(db, userId);
145
- await setDoc(ref, {
146
- isPremium: false,
147
- status: SUBSCRIPTION_STATUS.EXPIRED,
148
- willRenew: false,
149
- expirationDate: serverTimestamp(),
150
- }, { merge: true });
69
+ await syncExpiredStatus(this.getRef(db, userId));
151
70
  }
152
71
  }
153
72
 
@@ -0,0 +1,23 @@
1
+ import { getDoc } from "firebase/firestore";
2
+ import type { Firestore, DocumentReference } from "@umituz/react-native-firebase";
3
+ import type { CreditsResult } from "../../core/Credits";
4
+ import type { UserCreditsDocumentRead } from "../../core/UserCreditsDocument";
5
+ import { mapCreditsDocumentToEntity } from "../../core/CreditsMapper";
6
+ import { requireFirestore } from "../../../../shared/infrastructure/firestore";
7
+
8
+ export async function fetchCredits(ref: DocumentReference): Promise<CreditsResult> {
9
+ const snap = await getDoc(ref);
10
+
11
+ if (!snap.exists()) {
12
+ return { success: true, data: null, error: null };
13
+ }
14
+
15
+ const entity = mapCreditsDocumentToEntity(snap.data() as UserCreditsDocumentRead);
16
+ return { success: true, data: entity, error: null };
17
+ }
18
+
19
+ export async function checkHasCredits(ref: DocumentReference, cost: number): Promise<boolean> {
20
+ const result = await fetchCredits(ref);
21
+ if (!result.success || !result.data) return false;
22
+ return result.data.credits >= cost;
23
+ }
@@ -0,0 +1,98 @@
1
+ import type { Firestore, DocumentReference } from "@umituz/react-native-firebase";
2
+ import type { CreditsConfig, CreditsResult } from "../../core/Credits";
3
+ import type { UserCreditsDocumentRead, PurchaseSource } from "../../core/UserCreditsDocument";
4
+ import { initializeCreditsTransaction } from "../../application/CreditsInitializer";
5
+ import { mapCreditsDocumentToEntity } from "../../core/CreditsMapper";
6
+ import type { RevenueCatData } from "../../../revenuecat/core/types";
7
+ import { calculateCreditLimit } from "../../application/CreditLimitCalculator";
8
+ import { PURCHASE_TYPE, type PurchaseType } from "../../../subscription/core/SubscriptionConstants";
9
+
10
+ interface InitializeCreditsParams {
11
+ db: Firestore;
12
+ ref: DocumentReference;
13
+ config: CreditsConfig;
14
+ userId: string;
15
+ purchaseId: string;
16
+ productId: string;
17
+ source: PurchaseSource;
18
+ revenueCatData: RevenueCatData;
19
+ type?: PurchaseType;
20
+ }
21
+
22
+ function isTransientError(error: any): boolean {
23
+ return (
24
+ error?.code === 'already-exists' ||
25
+ error?.code === 'DEADLINE_EXCEEDED' ||
26
+ error?.code === 'UNAVAILABLE' ||
27
+ error?.code === 'RESOURCE_EXHAUSTED' ||
28
+ error?.message?.includes('already-exists') ||
29
+ error?.message?.includes('timeout') ||
30
+ error?.message?.includes('unavailable')
31
+ );
32
+ }
33
+
34
+ export async function initializeCreditsWithRetry(params: InitializeCreditsParams): Promise<CreditsResult> {
35
+ const { db, ref, config, purchaseId, productId, source, revenueCatData, type = PURCHASE_TYPE.INITIAL } = params;
36
+
37
+ const creditLimit = calculateCreditLimit(productId, config);
38
+ const cfg = { ...config, creditLimit };
39
+
40
+ const maxRetries = 3;
41
+ let lastError: any;
42
+
43
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
44
+ try {
45
+ const result = await initializeCreditsTransaction(
46
+ db,
47
+ ref,
48
+ cfg,
49
+ purchaseId,
50
+ {
51
+ productId,
52
+ source,
53
+ expirationDate: revenueCatData.expirationDate,
54
+ willRenew: revenueCatData.willRenew,
55
+ originalTransactionId: revenueCatData.originalTransactionId,
56
+ isPremium: revenueCatData.isPremium,
57
+ periodType: revenueCatData.periodType,
58
+ unsubscribeDetectedAt: revenueCatData.unsubscribeDetectedAt,
59
+ billingIssueDetectedAt: revenueCatData.billingIssueDetectedAt,
60
+ store: revenueCatData.store,
61
+ ownershipType: revenueCatData.ownershipType,
62
+ type,
63
+ }
64
+ );
65
+
66
+ return {
67
+ success: true,
68
+ data: result.finalData ? mapCreditsDocumentToEntity(result.finalData) : null,
69
+ error: null,
70
+ };
71
+ } catch (error: any) {
72
+ lastError = error;
73
+
74
+ if (isTransientError(error) && attempt < maxRetries - 1) {
75
+ await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
76
+ continue;
77
+ }
78
+ break;
79
+ }
80
+ }
81
+
82
+ const errorMessage = lastError instanceof Error
83
+ ? lastError.message
84
+ : typeof lastError === 'string'
85
+ ? lastError
86
+ : 'Unknown error during credit initialization';
87
+
88
+ const errorCode = lastError?.code ?? 'UNKNOWN_ERROR';
89
+
90
+ return {
91
+ success: false,
92
+ data: null,
93
+ error: {
94
+ message: errorMessage,
95
+ code: errorCode,
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,13 @@
1
+ import { setDoc } from "firebase/firestore";
2
+ import type { DocumentReference } from "@umituz/react-native-firebase";
3
+ import { serverTimestamp } from "@umituz/react-native-firebase";
4
+ import { SUBSCRIPTION_STATUS } from "../../../subscription/core/SubscriptionConstants";
5
+
6
+ export async function syncExpiredStatus(ref: DocumentReference): Promise<void> {
7
+ await setDoc(ref, {
8
+ isPremium: false,
9
+ status: SUBSCRIPTION_STATUS.EXPIRED,
10
+ willRenew: false,
11
+ expirationDate: serverTimestamp(),
12
+ }, { merge: true });
13
+ }
@@ -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/RenewalDetector";
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/RenewalDetector";
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,105 @@
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
+ isAlreadyPurchasedError,
11
+ isInvalidCredentialsError,
12
+ getRawErrorMessage,
13
+ getErrorCode,
14
+ } from "../../../../revenuecat/core/types";
15
+ import { getSavedPurchase, clearSavedPurchase } from "../../../presentation/useAuthAwarePurchase";
16
+ import { notifyPurchaseCompleted } from "../../utils/PremiumStatusSyncer";
17
+ import { handleRestore } from "../RestoreHandler";
18
+ import type { PurchaseHandlerDeps } from "../PurchaseHandler";
19
+
20
+ export async function handleAlreadyPurchasedError(
21
+ deps: PurchaseHandlerDeps,
22
+ userId: string,
23
+ pkg: PurchasesPackage,
24
+ error: unknown
25
+ ): Promise<PurchaseResult> {
26
+ try {
27
+ const restoreResult = await handleRestore(deps, userId);
28
+ if (restoreResult.success && restoreResult.isPremium && restoreResult.customerInfo) {
29
+ await notifyPurchaseCompleted(
30
+ deps.config,
31
+ userId,
32
+ pkg.product.identifier,
33
+ restoreResult.customerInfo,
34
+ getSavedPurchase()?.source
35
+ );
36
+ clearSavedPurchase();
37
+ return {
38
+ success: true,
39
+ isPremium: true,
40
+ customerInfo: restoreResult.customerInfo,
41
+ productId: restoreResult.productId || pkg.product.identifier,
42
+ };
43
+ }
44
+ } catch (_restoreError) {
45
+ throw new RevenueCatPurchaseError(
46
+ "You already own this subscription, but restore failed. Please try restoring purchases manually.",
47
+ pkg.product.identifier,
48
+ error instanceof Error ? error : undefined
49
+ );
50
+ }
51
+
52
+ throw new RevenueCatPurchaseError(
53
+ "You already own this subscription, but it could not be activated.",
54
+ pkg.product.identifier,
55
+ error instanceof Error ? error : undefined
56
+ );
57
+ }
58
+
59
+ export function handlePurchaseError(
60
+ error: unknown,
61
+ pkg: PurchasesPackage,
62
+ userId: string
63
+ ): never {
64
+ if (isUserCancelledError(error)) {
65
+ throw new RevenueCatPurchaseError(
66
+ "Purchase cancelled",
67
+ pkg.product.identifier,
68
+ error instanceof Error ? error : undefined
69
+ );
70
+ }
71
+
72
+ if (isNetworkError(error)) {
73
+ throw new RevenueCatNetworkError(
74
+ "Network error during purchase. Please check your internet connection and try again.",
75
+ error instanceof Error ? error : undefined
76
+ );
77
+ }
78
+
79
+ if (isInvalidCredentialsError(error)) {
80
+ throw new RevenueCatPurchaseError(
81
+ "App configuration error. Please contact support.",
82
+ pkg.product.identifier,
83
+ error instanceof Error ? error : undefined
84
+ );
85
+ }
86
+
87
+ const errorCode = getErrorCode(error);
88
+ const errorMessage = getRawErrorMessage(error, "Purchase failed");
89
+ const enhancedMessage = errorCode
90
+ ? `${errorMessage} (Code: ${errorCode})`
91
+ : errorMessage;
92
+
93
+ console.error('[PurchaseHandler] Purchase failed', {
94
+ productId: pkg.product.identifier,
95
+ userId,
96
+ errorCode,
97
+ error,
98
+ });
99
+
100
+ throw new RevenueCatPurchaseError(
101
+ enhancedMessage,
102
+ pkg.product.identifier,
103
+ error instanceof Error ? error : undefined
104
+ );
105
+ }
@@ -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
+ }