@umituz/react-native-subscription 2.31.26 → 2.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/domains/credits/infrastructure/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/revenuecat/infrastructure/services/ConfigurationStateManager.ts +49 -0
- package/src/domains/revenuecat/infrastructure/services/RevenueCatInitializer.ts +8 -141
- package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +94 -0
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +14 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.32.1",
|
|
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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
2
|
+
|
|
3
|
+
export class ConfigurationStateManager {
|
|
4
|
+
private _isPurchasesConfigured = false;
|
|
5
|
+
private _configurationPromise: Promise<InitializeResult> | null = null;
|
|
6
|
+
private _resolveConfiguration: ((value: InitializeResult) => void) | null = null;
|
|
7
|
+
|
|
8
|
+
get isPurchasesConfigured(): boolean {
|
|
9
|
+
return this._isPurchasesConfigured;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get configurationPromise(): Promise<InitializeResult> | null {
|
|
13
|
+
return this._configurationPromise;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get isConfiguring(): boolean {
|
|
17
|
+
return this._configurationPromise !== null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
startConfiguration(): (value: InitializeResult) => void {
|
|
21
|
+
if (this._configurationPromise) {
|
|
22
|
+
throw new Error('Configuration already in progress');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this._configurationPromise = new Promise((resolve) => {
|
|
26
|
+
this._resolveConfiguration = resolve;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return (value: InitializeResult) => {
|
|
30
|
+
if (this._resolveConfiguration) {
|
|
31
|
+
this._resolveConfiguration(value);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
completeConfiguration(success: boolean): void {
|
|
37
|
+
this._isPurchasesConfigured = success;
|
|
38
|
+
this._configurationPromise = null;
|
|
39
|
+
this._resolveConfiguration = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
reset(): void {
|
|
43
|
+
this._isPurchasesConfigured = false;
|
|
44
|
+
this._configurationPromise = null;
|
|
45
|
+
this._resolveConfiguration = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const configState = new ConfigurationStateManager();
|
|
@@ -1,133 +1,25 @@
|
|
|
1
|
-
import Purchases, { type CustomerInfo, type PurchasesOfferings } from "react-native-purchases";
|
|
2
1
|
import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
3
2
|
import { resolveApiKey } from "../utils/ApiKeyResolver";
|
|
4
3
|
import type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
5
4
|
import { FAILED_INITIALIZATION_RESULT, CONFIGURATION_RETRY_DELAY_MS, MAX_INIT_RETRIES } from "./initializerConstants";
|
|
5
|
+
import { configState } from "./ConfigurationStateManager";
|
|
6
|
+
import { handleUserSwitch, handleInitialConfiguration, fetchCurrentUserData } from "./userSwitchHandler";
|
|
6
7
|
|
|
7
8
|
export type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
* Thread-safe configuration state manager
|
|
11
|
-
* Prevents race conditions during concurrent initialization attempts
|
|
12
|
-
*/
|
|
13
|
-
class ConfigurationStateManager {
|
|
14
|
-
private _isPurchasesConfigured = false;
|
|
15
|
-
private _configurationPromise: Promise<InitializeResult> | null = null;
|
|
16
|
-
private _resolveConfiguration: ((value: InitializeResult) => void) | null = null;
|
|
17
|
-
|
|
18
|
-
get isPurchasesConfigured(): boolean {
|
|
19
|
-
return this._isPurchasesConfigured;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
get configurationPromise(): Promise<InitializeResult> | null {
|
|
23
|
-
return this._configurationPromise;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
get isConfiguring(): boolean {
|
|
27
|
-
return this._configurationPromise !== null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Starts a new configuration process
|
|
32
|
-
* @throws Error if configuration is already in progress
|
|
33
|
-
*/
|
|
34
|
-
startConfiguration(): (value: InitializeResult) => void {
|
|
35
|
-
if (this._configurationPromise) {
|
|
36
|
-
throw new Error('Configuration already in progress');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
this._configurationPromise = new Promise((resolve) => {
|
|
40
|
-
this._resolveConfiguration = resolve;
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
return (value: InitializeResult) => {
|
|
44
|
-
if (this._resolveConfiguration) {
|
|
45
|
-
this._resolveConfiguration(value);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Completes the configuration process
|
|
52
|
-
*/
|
|
53
|
-
completeConfiguration(success: boolean): void {
|
|
54
|
-
this._isPurchasesConfigured = success;
|
|
55
|
-
this._configurationPromise = null;
|
|
56
|
-
this._resolveConfiguration = null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Resets the configuration state
|
|
61
|
-
*/
|
|
62
|
-
reset(): void {
|
|
63
|
-
this._isPurchasesConfigured = false;
|
|
64
|
-
this._configurationPromise = null;
|
|
65
|
-
this._resolveConfiguration = null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const configState = new ConfigurationStateManager();
|
|
70
|
-
|
|
71
|
-
function buildSuccessResult(deps: InitializerDeps, customerInfo: CustomerInfo, offerings: PurchasesOfferings): InitializeResult {
|
|
72
|
-
const isPremium = !!customerInfo.entitlements.active[deps.config.entitlementIdentifier];
|
|
73
|
-
return { success: true, offering: offerings.current, isPremium };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
10
|
export async function initializeSDK(
|
|
77
11
|
deps: InitializerDeps,
|
|
78
12
|
userId: string,
|
|
79
13
|
apiKey?: string
|
|
80
14
|
): Promise<InitializeResult> {
|
|
81
15
|
if (deps.isInitialized() && deps.getCurrentUserId() === userId) {
|
|
82
|
-
|
|
83
|
-
const [customerInfo, offerings] = await Promise.all([
|
|
84
|
-
Purchases.getCustomerInfo(),
|
|
85
|
-
Purchases.getOfferings(),
|
|
86
|
-
]);
|
|
87
|
-
return buildSuccessResult(deps, customerInfo, offerings);
|
|
88
|
-
} catch (error) {
|
|
89
|
-
console.error('[RevenueCatInitializer] Failed to fetch customer info/offerings for initialized user', {
|
|
90
|
-
userId,
|
|
91
|
-
error
|
|
92
|
-
});
|
|
93
|
-
return FAILED_INITIALIZATION_RESULT;
|
|
94
|
-
}
|
|
16
|
+
return fetchCurrentUserData(deps);
|
|
95
17
|
}
|
|
96
18
|
|
|
97
19
|
if (configState.isPurchasesConfigured) {
|
|
98
|
-
|
|
99
|
-
const currentAppUserId = await Purchases.getAppUserID();
|
|
100
|
-
let customerInfo;
|
|
101
|
-
|
|
102
|
-
// Handle user switching
|
|
103
|
-
if (currentAppUserId !== userId) {
|
|
104
|
-
if (userId) {
|
|
105
|
-
// Switch to authenticated user
|
|
106
|
-
const result = await Purchases.logIn(userId);
|
|
107
|
-
customerInfo = result.customerInfo;
|
|
108
|
-
} else {
|
|
109
|
-
// User logged out - switch to anonymous
|
|
110
|
-
customerInfo = await Purchases.logOut();
|
|
111
|
-
}
|
|
112
|
-
} else {
|
|
113
|
-
customerInfo = await Purchases.getCustomerInfo();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
deps.setInitialized(true);
|
|
117
|
-
deps.setCurrentUserId(userId ?? null);
|
|
118
|
-
const offerings = await Purchases.getOfferings();
|
|
119
|
-
return buildSuccessResult(deps, customerInfo, offerings);
|
|
120
|
-
} catch (error) {
|
|
121
|
-
console.error('[RevenueCatInitializer] Failed during user switch or fetch', {
|
|
122
|
-
userId,
|
|
123
|
-
currentAppUserId: await Purchases.getAppUserID().catch(() => 'unknown'),
|
|
124
|
-
error
|
|
125
|
-
});
|
|
126
|
-
return FAILED_INITIALIZATION_RESULT;
|
|
127
|
-
}
|
|
20
|
+
return handleUserSwitch(deps, userId);
|
|
128
21
|
}
|
|
129
22
|
|
|
130
|
-
// Wait for ongoing configuration with retry limit
|
|
131
23
|
if (configState.isConfiguring) {
|
|
132
24
|
let retryCount = 0;
|
|
133
25
|
while (configState.isConfiguring && retryCount < MAX_INIT_RETRIES) {
|
|
@@ -144,7 +36,6 @@ export async function initializeSDK(
|
|
|
144
36
|
return FAILED_INITIALIZATION_RESULT;
|
|
145
37
|
}
|
|
146
38
|
|
|
147
|
-
// Configuration completed, try again
|
|
148
39
|
return initializeSDK(deps, userId, apiKey);
|
|
149
40
|
}
|
|
150
41
|
|
|
@@ -161,36 +52,12 @@ export async function initializeSDK(
|
|
|
161
52
|
userId,
|
|
162
53
|
error
|
|
163
54
|
});
|
|
164
|
-
// Configuration already in progress, wait and retry
|
|
165
55
|
await new Promise(resolve => setTimeout(resolve, CONFIGURATION_RETRY_DELAY_MS));
|
|
166
56
|
return initializeSDK(deps, userId, apiKey);
|
|
167
57
|
}
|
|
168
58
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
deps.setInitialized(true);
|
|
174
|
-
deps.setCurrentUserId(userId ?? null);
|
|
175
|
-
|
|
176
|
-
const [customerInfo, offerings] = await Promise.all([
|
|
177
|
-
Purchases.getCustomerInfo(),
|
|
178
|
-
Purchases.getOfferings(),
|
|
179
|
-
]);
|
|
180
|
-
|
|
181
|
-
const result = buildSuccessResult(deps, customerInfo, offerings);
|
|
182
|
-
configState.completeConfiguration(true);
|
|
183
|
-
resolveConfig(result);
|
|
184
|
-
return result;
|
|
185
|
-
} catch (error) {
|
|
186
|
-
console.error('[RevenueCatInitializer] SDK configuration failed', {
|
|
187
|
-
userId,
|
|
188
|
-
apiKey: apiKey ? 'provided' : 'from config',
|
|
189
|
-
error
|
|
190
|
-
});
|
|
191
|
-
configState.completeConfiguration(false);
|
|
192
|
-
resolveConfig(FAILED_INITIALIZATION_RESULT);
|
|
193
|
-
return FAILED_INITIALIZATION_RESULT;
|
|
194
|
-
}
|
|
59
|
+
const result = await handleInitialConfiguration(deps, userId, key);
|
|
60
|
+
configState.completeConfiguration(result.success);
|
|
61
|
+
resolveConfig(result);
|
|
62
|
+
return result;
|
|
195
63
|
}
|
|
196
|
-
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import Purchases, { type CustomerInfo } from "react-native-purchases";
|
|
2
|
+
import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
3
|
+
import type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
4
|
+
import { FAILED_INITIALIZATION_RESULT } from "./initializerConstants";
|
|
5
|
+
|
|
6
|
+
function buildSuccessResult(deps: InitializerDeps, customerInfo: CustomerInfo, offerings: any): InitializeResult {
|
|
7
|
+
const isPremium = !!customerInfo.entitlements.active[deps.config.entitlementIdentifier];
|
|
8
|
+
return { success: true, offering: offerings.current, isPremium };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeUserId(userId: string): string | null {
|
|
12
|
+
return (userId && userId.length > 0) ? userId : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isAnonymousId(userId: string): boolean {
|
|
16
|
+
return userId.startsWith('$RCAnonymous');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function handleUserSwitch(
|
|
20
|
+
deps: InitializerDeps,
|
|
21
|
+
userId: string
|
|
22
|
+
): Promise<InitializeResult> {
|
|
23
|
+
try {
|
|
24
|
+
const currentAppUserId = await Purchases.getAppUserID();
|
|
25
|
+
let customerInfo;
|
|
26
|
+
|
|
27
|
+
const normalizedUserId = normalizeUserId(userId);
|
|
28
|
+
const normalizedCurrentUserId = isAnonymousId(currentAppUserId) ? null : currentAppUserId;
|
|
29
|
+
|
|
30
|
+
if (normalizedCurrentUserId !== normalizedUserId) {
|
|
31
|
+
if (normalizedUserId) {
|
|
32
|
+
const result = await Purchases.logIn(normalizedUserId);
|
|
33
|
+
customerInfo = result.customerInfo;
|
|
34
|
+
} else {
|
|
35
|
+
customerInfo = await Purchases.getCustomerInfo();
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
customerInfo = await Purchases.getCustomerInfo();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
deps.setInitialized(true);
|
|
42
|
+
deps.setCurrentUserId(normalizedUserId);
|
|
43
|
+
const offerings = await Purchases.getOfferings();
|
|
44
|
+
return buildSuccessResult(deps, customerInfo, offerings);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error('[UserSwitchHandler] Failed during user switch or fetch', {
|
|
47
|
+
userId,
|
|
48
|
+
currentAppUserId: await Purchases.getAppUserID().catch(() => 'unknown'),
|
|
49
|
+
error
|
|
50
|
+
});
|
|
51
|
+
return FAILED_INITIALIZATION_RESULT;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function handleInitialConfiguration(
|
|
56
|
+
deps: InitializerDeps,
|
|
57
|
+
userId: string,
|
|
58
|
+
apiKey: string
|
|
59
|
+
): Promise<InitializeResult> {
|
|
60
|
+
try {
|
|
61
|
+
const normalizedUserId = normalizeUserId(userId);
|
|
62
|
+
await Purchases.configure({ apiKey, appUserID: normalizedUserId });
|
|
63
|
+
deps.setInitialized(true);
|
|
64
|
+
deps.setCurrentUserId(normalizedUserId);
|
|
65
|
+
|
|
66
|
+
const [customerInfo, offerings] = await Promise.all([
|
|
67
|
+
Purchases.getCustomerInfo(),
|
|
68
|
+
Purchases.getOfferings(),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
return buildSuccessResult(deps, customerInfo, offerings);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('[UserSwitchHandler] SDK configuration failed', {
|
|
74
|
+
userId,
|
|
75
|
+
error
|
|
76
|
+
});
|
|
77
|
+
return FAILED_INITIALIZATION_RESULT;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function fetchCurrentUserData(deps: InitializerDeps): Promise<InitializeResult> {
|
|
82
|
+
try {
|
|
83
|
+
const [customerInfo, offerings] = await Promise.all([
|
|
84
|
+
Purchases.getCustomerInfo(),
|
|
85
|
+
Purchases.getOfferings(),
|
|
86
|
+
]);
|
|
87
|
+
return buildSuccessResult(deps, customerInfo, offerings);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('[UserSwitchHandler] Failed to fetch customer info/offerings for initialized user', {
|
|
90
|
+
error
|
|
91
|
+
});
|
|
92
|
+
return FAILED_INITIALIZATION_RESULT;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -34,21 +34,30 @@ class SubscriptionManagerImpl {
|
|
|
34
34
|
async initialize(userId?: string): Promise<boolean> {
|
|
35
35
|
this.ensureConfigured();
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
let actualUserId: string | null = null;
|
|
38
|
+
|
|
39
|
+
if (userId && userId.length > 0) {
|
|
40
|
+
actualUserId = userId;
|
|
41
|
+
} else {
|
|
42
|
+
const anonymousId = await this.managerConfig.getAnonymousUserId();
|
|
43
|
+
actualUserId = (anonymousId && anonymousId.length > 0) ? anonymousId : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cacheKey = actualUserId ?? '__anonymous__';
|
|
47
|
+
const { shouldInit, existingPromise } = this.state.initCache.tryAcquireInitialization(cacheKey);
|
|
39
48
|
|
|
40
49
|
if (!shouldInit && existingPromise) {
|
|
41
50
|
return existingPromise;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
const promise = this.performInitialization(actualUserId);
|
|
45
|
-
this.state.initCache.setPromise(promise,
|
|
54
|
+
this.state.initCache.setPromise(promise, cacheKey);
|
|
46
55
|
return promise;
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
private async performInitialization(userId: string): Promise<boolean> {
|
|
58
|
+
private async performInitialization(userId: string | null): Promise<boolean> {
|
|
50
59
|
this.ensureConfigured();
|
|
51
|
-
const { service, success } = await performServiceInitialization(this.managerConfig.config, userId);
|
|
60
|
+
const { service, success } = await performServiceInitialization(this.managerConfig.config, userId ?? '');
|
|
52
61
|
this.serviceInstance = service ?? null;
|
|
53
62
|
this.ensurePackageHandlerInitialized();
|
|
54
63
|
return success;
|