@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 +1 -1
- package/src/domains/credits/infrastructure/CreditsRepository.ts +21 -102
- package/src/domains/credits/infrastructure/operations/CreditsFetcher.ts +23 -0
- package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +98 -0
- package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +13 -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 +105 -0
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +70 -0
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseValidator.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.
|
|
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 {
|
|
2
|
-
import { BaseRepository
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
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 {
|
|
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/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
|
+
}
|