@umituz/react-native-subscription 2.27.39 → 2.27.40
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/domain/types/RevenueCatData.ts +12 -0
- package/src/infrastructure/repositories/CreditsRepository.ts +14 -112
- package/src/infrastructure/services/FreeCreditsService.ts +83 -0
- package/src/infrastructure/services/SubscriptionInitializer.ts +40 -139
- package/src/infrastructure/services/SubscriptionInitializerTypes.ts +30 -0
- package/src/infrastructure/services/TrialService.ts +14 -70
- package/src/infrastructure/services/TrialTypes.ts +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.40",
|
|
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",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RevenueCat subscription data (Single Source of Truth)
|
|
3
|
+
* Used across the subscription package for storing RevenueCat data in Firestore
|
|
4
|
+
*/
|
|
5
|
+
export interface RevenueCatData {
|
|
6
|
+
expirationDate?: string | null;
|
|
7
|
+
willRenew?: boolean;
|
|
8
|
+
originalTransactionId?: string;
|
|
9
|
+
isPremium?: boolean;
|
|
10
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
11
|
+
periodType?: "NORMAL" | "INTRO" | "TRIAL";
|
|
12
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Credits Repository
|
|
3
3
|
*/
|
|
4
|
-
|
|
5
4
|
declare const __DEV__: boolean;
|
|
6
5
|
|
|
7
6
|
import { doc, getDoc, runTransaction, serverTimestamp, type Firestore, type Transaction } from "firebase/firestore";
|
|
@@ -11,18 +10,11 @@ import type { UserCreditsDocumentRead, PurchaseSource } from "../models/UserCred
|
|
|
11
10
|
import { initializeCreditsTransaction, type InitializeCreditsMetadata } from "../services/CreditsInitializer";
|
|
12
11
|
import { detectPackageType } from "../../utils/packageTypeDetector";
|
|
13
12
|
import { getCreditAllocation } from "../../utils/creditMapper";
|
|
14
|
-
|
|
15
13
|
import { CreditsMapper } from "../mappers/CreditsMapper";
|
|
14
|
+
import type { RevenueCatData } from "../../domain/types/RevenueCatData";
|
|
15
|
+
import { initializeFreeCredits as initializeFreeCreditsService } from "../services/FreeCreditsService";
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
export interface RevenueCatData {
|
|
19
|
-
expirationDate?: string | null;
|
|
20
|
-
willRenew?: boolean;
|
|
21
|
-
originalTransactionId?: string;
|
|
22
|
-
isPremium?: boolean;
|
|
23
|
-
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
24
|
-
periodType?: "NORMAL" | "INTRO" | "TRIAL";
|
|
25
|
-
}
|
|
17
|
+
export type { RevenueCatData } from "../../domain/types/RevenueCatData";
|
|
26
18
|
|
|
27
19
|
export class CreditsRepository extends BaseRepository {
|
|
28
20
|
constructor(private config: CreditsConfig) { super(); }
|
|
@@ -58,11 +50,8 @@ export class CreditsRepository extends BaseRepository {
|
|
|
58
50
|
}
|
|
59
51
|
|
|
60
52
|
async initializeCredits(
|
|
61
|
-
userId: string,
|
|
62
|
-
|
|
63
|
-
productId?: string,
|
|
64
|
-
source?: PurchaseSource,
|
|
65
|
-
revenueCatData?: RevenueCatData
|
|
53
|
+
userId: string, purchaseId?: string, productId?: string,
|
|
54
|
+
source?: PurchaseSource, revenueCatData?: RevenueCatData
|
|
66
55
|
): Promise<CreditsResult> {
|
|
67
56
|
const db = getFirestore();
|
|
68
57
|
if (!db) return { success: false, error: { message: "No DB", code: "INIT_ERR" } };
|
|
@@ -79,9 +68,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
79
68
|
}
|
|
80
69
|
|
|
81
70
|
const metadata: InitializeCreditsMetadata = {
|
|
82
|
-
productId,
|
|
83
|
-
source,
|
|
84
|
-
// RevenueCat data for Single Source of Truth
|
|
71
|
+
productId, source,
|
|
85
72
|
expirationDate: revenueCatData?.expirationDate,
|
|
86
73
|
willRenew: revenueCatData?.willRenew,
|
|
87
74
|
originalTransactionId: revenueCatData?.originalTransactionId,
|
|
@@ -89,23 +76,14 @@ export class CreditsRepository extends BaseRepository {
|
|
|
89
76
|
periodType: revenueCatData?.periodType,
|
|
90
77
|
};
|
|
91
78
|
|
|
92
|
-
const res = await initializeCreditsTransaction(
|
|
93
|
-
db,
|
|
94
|
-
this.getRef(db, userId),
|
|
95
|
-
cfg,
|
|
96
|
-
purchaseId,
|
|
97
|
-
metadata
|
|
98
|
-
);
|
|
99
|
-
|
|
79
|
+
const res = await initializeCreditsTransaction(db, this.getRef(db, userId), cfg, purchaseId, metadata);
|
|
100
80
|
return {
|
|
101
81
|
success: true,
|
|
102
|
-
data: CreditsMapper.toEntity({
|
|
103
|
-
...res,
|
|
104
|
-
purchasedAt: undefined,
|
|
105
|
-
lastUpdatedAt: undefined,
|
|
106
|
-
})
|
|
82
|
+
data: CreditsMapper.toEntity({ ...res, purchasedAt: undefined, lastUpdatedAt: undefined })
|
|
107
83
|
};
|
|
108
|
-
} catch (e: any) {
|
|
84
|
+
} catch (e: any) {
|
|
85
|
+
return { success: false, error: { message: e.message, code: "INIT_ERR" } };
|
|
86
|
+
}
|
|
109
87
|
}
|
|
110
88
|
|
|
111
89
|
async deductCredit(userId: string, cost: number = 1): Promise<DeductCreditsResult> {
|
|
@@ -133,98 +111,22 @@ export class CreditsRepository extends BaseRepository {
|
|
|
133
111
|
return !!(res.success && res.data && res.data.credits >= cost);
|
|
134
112
|
}
|
|
135
113
|
|
|
136
|
-
/**
|
|
137
|
-
* Initialize free credits for new users
|
|
138
|
-
* Creates a credits document with freeCredits amount (no subscription)
|
|
139
|
-
* Uses transaction to prevent race condition with premium init
|
|
140
|
-
*/
|
|
141
114
|
async initializeFreeCredits(userId: string): Promise<CreditsResult> {
|
|
142
|
-
|
|
143
|
-
if (!db) return { success: false, error: { message: "No DB", code: "INIT_ERR" } };
|
|
144
|
-
|
|
145
|
-
const freeCredits = this.config.freeCredits ?? 0;
|
|
146
|
-
if (freeCredits <= 0) {
|
|
147
|
-
return { success: false, error: { message: "Free credits not configured", code: "NO_FREE_CREDITS" } };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
const ref = this.getRef(db, userId);
|
|
152
|
-
|
|
153
|
-
// Use transaction to atomically check-and-set
|
|
154
|
-
const result = await runTransaction(db, async (tx: Transaction) => {
|
|
155
|
-
const snap = await tx.get(ref);
|
|
156
|
-
|
|
157
|
-
// Don't overwrite if document already exists (premium or previous init)
|
|
158
|
-
if (snap.exists()) {
|
|
159
|
-
if (__DEV__) console.log("[CreditsRepository] Credits document already exists, skipping free credits init");
|
|
160
|
-
const existing = snap.data() as UserCreditsDocumentRead;
|
|
161
|
-
return { skipped: true, data: CreditsMapper.toEntity(existing) };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Create new document with free credits
|
|
165
|
-
const now = serverTimestamp();
|
|
166
|
-
|
|
167
|
-
const creditsData = {
|
|
168
|
-
// Not premium - just free credits
|
|
169
|
-
isPremium: false,
|
|
170
|
-
status: "free" as const,
|
|
171
|
-
|
|
172
|
-
// Free credits - store initial amount for tracking
|
|
173
|
-
credits: freeCredits,
|
|
174
|
-
creditLimit: freeCredits,
|
|
175
|
-
initialFreeCredits: freeCredits,
|
|
176
|
-
isFreeCredits: true,
|
|
177
|
-
|
|
178
|
-
// Dates
|
|
179
|
-
createdAt: now,
|
|
180
|
-
lastUpdatedAt: now,
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
tx.set(ref, creditsData);
|
|
184
|
-
|
|
185
|
-
if (__DEV__) console.log("[CreditsRepository] Initialized free credits:", { userId: userId.slice(0, 8), credits: freeCredits });
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
skipped: false,
|
|
189
|
-
data: {
|
|
190
|
-
isPremium: false,
|
|
191
|
-
status: "free" as const,
|
|
192
|
-
credits: freeCredits,
|
|
193
|
-
creditLimit: freeCredits,
|
|
194
|
-
purchasedAt: null,
|
|
195
|
-
expirationDate: null,
|
|
196
|
-
lastUpdatedAt: null,
|
|
197
|
-
willRenew: false,
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
return { success: true, data: result.data };
|
|
203
|
-
} catch (e: any) {
|
|
204
|
-
if (__DEV__) console.error("[CreditsRepository] Free credits init error:", e.message);
|
|
205
|
-
return { success: false, error: { message: e.message, code: "INIT_ERR" } };
|
|
206
|
-
}
|
|
115
|
+
return initializeFreeCreditsService({ config: this.config, getRef: this.getRef.bind(this) }, userId);
|
|
207
116
|
}
|
|
208
117
|
|
|
209
|
-
/** Sync expired subscription status to Firestore (background) */
|
|
210
118
|
async syncExpiredStatus(userId: string): Promise<void> {
|
|
211
119
|
const db = getFirestore();
|
|
212
120
|
if (!db) return;
|
|
213
|
-
|
|
214
121
|
try {
|
|
215
122
|
const ref = this.getRef(db, userId);
|
|
216
123
|
const { updateDoc } = await import("firebase/firestore");
|
|
217
|
-
await updateDoc(ref, {
|
|
218
|
-
|
|
219
|
-
status: "expired",
|
|
220
|
-
lastUpdatedAt: serverTimestamp(),
|
|
221
|
-
});
|
|
222
|
-
if (__DEV__) console.log("[CreditsRepository] Synced expired status for:", userId.slice(0, 8));
|
|
124
|
+
await updateDoc(ref, { isPremium: false, status: "expired", lastUpdatedAt: serverTimestamp() });
|
|
125
|
+
if (__DEV__) console.log("[CreditsRepository] Synced expired status:", userId.slice(0, 8));
|
|
223
126
|
} catch (e) {
|
|
224
127
|
if (__DEV__) console.error("[CreditsRepository] Sync expired failed:", e);
|
|
225
128
|
}
|
|
226
129
|
}
|
|
227
|
-
|
|
228
130
|
}
|
|
229
131
|
|
|
230
132
|
export const createCreditsRepository = (c: CreditsConfig) => new CreditsRepository(c);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free Credits Service
|
|
3
|
+
* Handles initialization of free credits for new users
|
|
4
|
+
*/
|
|
5
|
+
declare const __DEV__: boolean;
|
|
6
|
+
|
|
7
|
+
import { runTransaction, serverTimestamp, type Firestore, type DocumentReference, type Transaction } from "firebase/firestore";
|
|
8
|
+
import { getFirestore } from "@umituz/react-native-firebase";
|
|
9
|
+
import type { CreditsConfig, CreditsResult, UserCredits } from "../../domain/entities/Credits";
|
|
10
|
+
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
11
|
+
import { CreditsMapper } from "../mappers/CreditsMapper";
|
|
12
|
+
|
|
13
|
+
export interface FreeCreditsServiceConfig {
|
|
14
|
+
config: CreditsConfig;
|
|
15
|
+
getRef: (db: Firestore, userId: string) => DocumentReference;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Initialize free credits for new users
|
|
20
|
+
* Creates a credits document with freeCredits amount (no subscription)
|
|
21
|
+
* Uses transaction to prevent race condition with premium init
|
|
22
|
+
*/
|
|
23
|
+
export async function initializeFreeCredits(
|
|
24
|
+
deps: FreeCreditsServiceConfig,
|
|
25
|
+
userId: string
|
|
26
|
+
): Promise<CreditsResult> {
|
|
27
|
+
const db = getFirestore();
|
|
28
|
+
if (!db) return { success: false, error: { message: "No DB", code: "INIT_ERR" } };
|
|
29
|
+
|
|
30
|
+
const freeCredits = deps.config.freeCredits ?? 0;
|
|
31
|
+
if (freeCredits <= 0) {
|
|
32
|
+
return { success: false, error: { message: "Free credits not configured", code: "NO_FREE_CREDITS" } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const ref = deps.getRef(db, userId);
|
|
37
|
+
|
|
38
|
+
const result = await runTransaction(db, async (tx: Transaction) => {
|
|
39
|
+
const snap = await tx.get(ref);
|
|
40
|
+
|
|
41
|
+
// Don't overwrite if document already exists (premium or previous init)
|
|
42
|
+
if (snap.exists()) {
|
|
43
|
+
if (__DEV__) console.log("[FreeCreditsService] Credits document exists, skipping");
|
|
44
|
+
const existing = snap.data() as UserCreditsDocumentRead;
|
|
45
|
+
return { skipped: true, data: CreditsMapper.toEntity(existing) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Create new document with free credits
|
|
49
|
+
const now = serverTimestamp();
|
|
50
|
+
const creditsData = {
|
|
51
|
+
isPremium: false,
|
|
52
|
+
status: "free" as const,
|
|
53
|
+
credits: freeCredits,
|
|
54
|
+
creditLimit: freeCredits,
|
|
55
|
+
initialFreeCredits: freeCredits,
|
|
56
|
+
isFreeCredits: true,
|
|
57
|
+
createdAt: now,
|
|
58
|
+
lastUpdatedAt: now,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
tx.set(ref, creditsData);
|
|
62
|
+
|
|
63
|
+
if (__DEV__) console.log("[FreeCreditsService] Initialized:", { userId: userId.slice(0, 8), credits: freeCredits });
|
|
64
|
+
|
|
65
|
+
const entity: UserCredits = {
|
|
66
|
+
isPremium: false,
|
|
67
|
+
status: "free",
|
|
68
|
+
credits: freeCredits,
|
|
69
|
+
creditLimit: freeCredits,
|
|
70
|
+
purchasedAt: null,
|
|
71
|
+
expirationDate: null,
|
|
72
|
+
lastUpdatedAt: null,
|
|
73
|
+
willRenew: false,
|
|
74
|
+
};
|
|
75
|
+
return { skipped: false, data: entity };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { success: true, data: result.data };
|
|
79
|
+
} catch (e: any) {
|
|
80
|
+
if (__DEV__) console.error("[FreeCreditsService] Init error:", e.message);
|
|
81
|
+
return { success: false, error: { message: e.message, code: "INIT_ERR" } };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,38 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Subscription Initializer
|
|
3
3
|
*/
|
|
4
|
-
|
|
5
4
|
declare const __DEV__: boolean;
|
|
6
5
|
|
|
7
6
|
import { Platform } from "react-native";
|
|
8
7
|
import type { CustomerInfo } from "react-native-purchases";
|
|
9
|
-
import type { CreditsConfig } from "../../domain/entities/Credits";
|
|
10
8
|
import { configureCreditsRepository, getCreditsRepository } from "../repositories/CreditsRepositoryProvider";
|
|
11
9
|
import { SubscriptionManager } from "../../revenuecat/infrastructure/managers/SubscriptionManager";
|
|
12
10
|
import { configureAuthProvider } from "../../presentation/hooks/useAuthAwarePurchase";
|
|
13
|
-
import type { RevenueCatData } from "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
onAuthStateChanged: (callback: (user: { uid: string; isAnonymous: boolean } | null) => void) => () => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface CreditPackageConfig { identifierPattern?: string; amounts?: Record<string, number>; }
|
|
21
|
-
|
|
22
|
-
export interface SubscriptionInitConfig {
|
|
23
|
-
apiKey?: string;
|
|
24
|
-
apiKeyIos?: string;
|
|
25
|
-
apiKeyAndroid?: string;
|
|
26
|
-
entitlementId: string;
|
|
27
|
-
credits: CreditsConfig;
|
|
28
|
-
getAnonymousUserId: () => Promise<string>;
|
|
29
|
-
getFirebaseAuth: () => FirebaseAuthLike | null;
|
|
30
|
-
showAuthModal: () => void;
|
|
31
|
-
onCreditsUpdated?: (userId: string) => void;
|
|
32
|
-
creditPackages?: CreditPackageConfig;
|
|
33
|
-
timeoutMs?: number;
|
|
34
|
-
authStateTimeoutMs?: number;
|
|
35
|
-
}
|
|
11
|
+
import type { RevenueCatData } from "../../domain/types/RevenueCatData";
|
|
12
|
+
import type { SubscriptionInitConfig, FirebaseAuthLike } from "./SubscriptionInitializerTypes";
|
|
13
|
+
|
|
14
|
+
export type { FirebaseAuthLike, CreditPackageConfig, SubscriptionInitConfig } from "./SubscriptionInitializerTypes";
|
|
36
15
|
|
|
37
16
|
const waitForAuthState = async (getAuth: () => FirebaseAuthLike | null, timeoutMs: number): Promise<string | undefined> => {
|
|
38
17
|
const auth = getAuth();
|
|
@@ -44,6 +23,19 @@ const waitForAuthState = async (getAuth: () => FirebaseAuthLike | null, timeoutM
|
|
|
44
23
|
});
|
|
45
24
|
};
|
|
46
25
|
|
|
26
|
+
/** Extract RevenueCat data from CustomerInfo (Single Source of Truth) */
|
|
27
|
+
const extractRevenueCatData = (customerInfo: CustomerInfo, entitlementId: string): RevenueCatData => {
|
|
28
|
+
const entitlement = customerInfo.entitlements.active[entitlementId]
|
|
29
|
+
?? customerInfo.entitlements.all[entitlementId];
|
|
30
|
+
return {
|
|
31
|
+
expirationDate: entitlement?.expirationDate ?? customerInfo.latestExpirationDate ?? null,
|
|
32
|
+
willRenew: entitlement?.willRenew ?? false,
|
|
33
|
+
originalTransactionId: entitlement?.originalPurchaseDate ?? undefined,
|
|
34
|
+
isPremium: Object.keys(customerInfo.entitlements.active).length > 0,
|
|
35
|
+
periodType: entitlement?.periodType as "NORMAL" | "INTRO" | "TRIAL" | undefined,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
47
39
|
export const initializeSubscription = async (config: SubscriptionInitConfig): Promise<void> => {
|
|
48
40
|
const {
|
|
49
41
|
apiKey, apiKeyIos, apiKeyAndroid, entitlementId, credits,
|
|
@@ -56,148 +48,63 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
|
|
|
56
48
|
|
|
57
49
|
configureCreditsRepository({ ...credits, creditPackageAmounts: creditPackages?.amounts });
|
|
58
50
|
|
|
59
|
-
/** Extract RevenueCat data from CustomerInfo (Single Source of Truth) */
|
|
60
|
-
const extractRevenueCatData = (customerInfo: CustomerInfo, _productId: string): RevenueCatData => {
|
|
61
|
-
const entitlement = customerInfo.entitlements.active[entitlementId]
|
|
62
|
-
?? customerInfo.entitlements.all[entitlementId];
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
expirationDate: entitlement?.expirationDate ?? customerInfo.latestExpirationDate ?? null,
|
|
66
|
-
willRenew: entitlement?.willRenew ?? false,
|
|
67
|
-
originalTransactionId: entitlement?.originalPurchaseDate ?? undefined,
|
|
68
|
-
isPremium: Object.keys(customerInfo.entitlements.active).length > 0,
|
|
69
|
-
periodType: entitlement?.periodType as "NORMAL" | "INTRO" | "TRIAL" | undefined,
|
|
70
|
-
};
|
|
71
|
-
};
|
|
72
|
-
|
|
73
51
|
const onPurchase = async (userId: string, productId: string, customerInfo: CustomerInfo, source?: string) => {
|
|
74
|
-
if (__DEV__) {
|
|
75
|
-
console.log('[SubscriptionInitializer] onPurchase called:', { userId, productId, source });
|
|
76
|
-
}
|
|
52
|
+
if (__DEV__) console.log('[SubscriptionInitializer] onPurchase:', { userId, productId, source });
|
|
77
53
|
try {
|
|
78
|
-
const revenueCatData = extractRevenueCatData(customerInfo,
|
|
79
|
-
|
|
80
|
-
userId,
|
|
81
|
-
`purchase_${productId}_${Date.now()}`,
|
|
82
|
-
productId,
|
|
83
|
-
source as any,
|
|
84
|
-
revenueCatData
|
|
85
|
-
);
|
|
86
|
-
if (__DEV__) {
|
|
87
|
-
console.log('[SubscriptionInitializer] Credits initialized:', result);
|
|
88
|
-
}
|
|
54
|
+
const revenueCatData = extractRevenueCatData(customerInfo, entitlementId);
|
|
55
|
+
await getCreditsRepository().initializeCredits(userId, `purchase_${productId}_${Date.now()}`, productId, source as any, revenueCatData);
|
|
89
56
|
onCreditsUpdated?.(userId);
|
|
90
57
|
} catch (error) {
|
|
91
|
-
if (__DEV__)
|
|
92
|
-
console.error('[SubscriptionInitializer] Credits init failed:', error);
|
|
93
|
-
}
|
|
58
|
+
if (__DEV__) console.error('[SubscriptionInitializer] Credits init failed:', error);
|
|
94
59
|
}
|
|
95
60
|
};
|
|
96
61
|
|
|
97
62
|
const onRenewal = async (userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) => {
|
|
98
|
-
if (__DEV__) {
|
|
99
|
-
console.log('[SubscriptionInitializer] onRenewal called:', { userId, productId });
|
|
100
|
-
}
|
|
63
|
+
if (__DEV__) console.log('[SubscriptionInitializer] onRenewal:', { userId, productId });
|
|
101
64
|
try {
|
|
102
|
-
const revenueCatData = extractRevenueCatData(customerInfo,
|
|
103
|
-
// Update expiration date from renewal
|
|
65
|
+
const revenueCatData = extractRevenueCatData(customerInfo, entitlementId);
|
|
104
66
|
revenueCatData.expirationDate = newExpirationDate || revenueCatData.expirationDate;
|
|
105
|
-
|
|
106
|
-
userId,
|
|
107
|
-
`renewal_${productId}_${Date.now()}`,
|
|
108
|
-
productId,
|
|
109
|
-
"renewal" as any,
|
|
110
|
-
revenueCatData
|
|
111
|
-
);
|
|
112
|
-
if (__DEV__) {
|
|
113
|
-
console.log('[SubscriptionInitializer] Credits reset on renewal:', result);
|
|
114
|
-
}
|
|
67
|
+
await getCreditsRepository().initializeCredits(userId, `renewal_${productId}_${Date.now()}`, productId, "renewal" as any, revenueCatData);
|
|
115
68
|
onCreditsUpdated?.(userId);
|
|
116
69
|
} catch (error) {
|
|
117
|
-
if (__DEV__)
|
|
118
|
-
console.error('[SubscriptionInitializer] Renewal credits init failed:', error);
|
|
119
|
-
}
|
|
70
|
+
if (__DEV__) console.error('[SubscriptionInitializer] Renewal credits init failed:', error);
|
|
120
71
|
}
|
|
121
72
|
};
|
|
122
73
|
|
|
123
|
-
/** Sync premium status changes (including cancellation) to Firestore */
|
|
124
74
|
const onPremiumStatusChanged = async (
|
|
125
|
-
userId: string,
|
|
126
|
-
|
|
127
|
-
productId?: string,
|
|
128
|
-
expiresAt?: string,
|
|
129
|
-
willRenew?: boolean,
|
|
130
|
-
periodType?: "NORMAL" | "INTRO" | "TRIAL"
|
|
75
|
+
userId: string, isPremium: boolean, productId?: string,
|
|
76
|
+
expiresAt?: string, willRenew?: boolean, periodType?: "NORMAL" | "INTRO" | "TRIAL"
|
|
131
77
|
) => {
|
|
132
|
-
if (__DEV__) {
|
|
133
|
-
console.log('[SubscriptionInitializer] onPremiumStatusChanged:', { userId, isPremium, productId, willRenew, periodType });
|
|
134
|
-
}
|
|
78
|
+
if (__DEV__) console.log('[SubscriptionInitializer] onPremiumStatusChanged:', { userId, isPremium, productId, willRenew, periodType });
|
|
135
79
|
try {
|
|
136
80
|
// If not premium and no productId, this is a free user - don't overwrite free credits
|
|
137
81
|
if (!isPremium && !productId) {
|
|
138
|
-
if (__DEV__)
|
|
139
|
-
console.log('[SubscriptionInitializer] Free user detected, preserving free credits');
|
|
140
|
-
}
|
|
82
|
+
if (__DEV__) console.log('[SubscriptionInitializer] Free user detected, preserving free credits');
|
|
141
83
|
return;
|
|
142
84
|
}
|
|
143
85
|
|
|
144
86
|
// If premium became false, check if actually expired or just canceled
|
|
145
87
|
if (!isPremium && productId) {
|
|
146
|
-
// Check if subscription is truly expired (expiration date in the past)
|
|
147
88
|
const isActuallyExpired = !expiresAt || new Date(expiresAt) < new Date();
|
|
148
|
-
|
|
149
89
|
if (isActuallyExpired) {
|
|
150
|
-
// Subscription truly expired - zero out credits
|
|
151
90
|
await getCreditsRepository().syncExpiredStatus(userId);
|
|
152
|
-
if (__DEV__)
|
|
153
|
-
console.log('[SubscriptionInitializer] Subscription expired, synced status');
|
|
154
|
-
}
|
|
91
|
+
if (__DEV__) console.log('[SubscriptionInitializer] Subscription expired, synced status');
|
|
155
92
|
} else {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
// Update willRenew to false but keep credits
|
|
161
|
-
const revenueCatData: RevenueCatData = {
|
|
162
|
-
expirationDate: expiresAt,
|
|
163
|
-
willRenew: false, // Canceled
|
|
164
|
-
isPremium: true, // Still has access until expiration
|
|
165
|
-
periodType,
|
|
166
|
-
};
|
|
167
|
-
await getCreditsRepository().initializeCredits(
|
|
168
|
-
userId,
|
|
169
|
-
`status_sync_canceled_${Date.now()}`,
|
|
170
|
-
productId,
|
|
171
|
-
"settings" as any,
|
|
172
|
-
revenueCatData
|
|
173
|
-
);
|
|
93
|
+
if (__DEV__) console.log('[SubscriptionInitializer] Canceled but not expired, preserving until:', expiresAt);
|
|
94
|
+
const revenueCatData: RevenueCatData = { expirationDate: expiresAt, willRenew: false, isPremium: true, periodType };
|
|
95
|
+
await getCreditsRepository().initializeCredits(userId, `status_sync_canceled_${Date.now()}`, productId, "settings" as any, revenueCatData);
|
|
174
96
|
}
|
|
175
97
|
onCreditsUpdated?.(userId);
|
|
176
98
|
return;
|
|
177
99
|
}
|
|
178
100
|
|
|
179
101
|
// Premium user - initialize credits with subscription data
|
|
180
|
-
const revenueCatData: RevenueCatData = {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
isPremium,
|
|
184
|
-
periodType,
|
|
185
|
-
};
|
|
186
|
-
await getCreditsRepository().initializeCredits(
|
|
187
|
-
userId,
|
|
188
|
-
`status_sync_${Date.now()}`,
|
|
189
|
-
productId,
|
|
190
|
-
"settings" as any,
|
|
191
|
-
revenueCatData
|
|
192
|
-
);
|
|
193
|
-
if (__DEV__) {
|
|
194
|
-
console.log('[SubscriptionInitializer] Premium status synced to Firestore');
|
|
195
|
-
}
|
|
102
|
+
const revenueCatData: RevenueCatData = { expirationDate: expiresAt ?? null, willRenew: willRenew ?? false, isPremium, periodType };
|
|
103
|
+
await getCreditsRepository().initializeCredits(userId, `status_sync_${Date.now()}`, productId, "settings" as any, revenueCatData);
|
|
104
|
+
if (__DEV__) console.log('[SubscriptionInitializer] Premium status synced to Firestore');
|
|
196
105
|
onCreditsUpdated?.(userId);
|
|
197
106
|
} catch (error) {
|
|
198
|
-
if (__DEV__)
|
|
199
|
-
console.error('[SubscriptionInitializer] Premium status sync failed:', error);
|
|
200
|
-
}
|
|
107
|
+
if (__DEV__) console.error('[SubscriptionInitializer] Premium status sync failed:', error);
|
|
201
108
|
}
|
|
202
109
|
};
|
|
203
110
|
|
|
@@ -216,22 +123,16 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
|
|
|
216
123
|
});
|
|
217
124
|
|
|
218
125
|
const userId = await waitForAuthState(getFirebaseAuth, authStateTimeoutMs);
|
|
219
|
-
|
|
220
|
-
// Initialize subscription without blocking - let it complete in background
|
|
126
|
+
|
|
221
127
|
try {
|
|
222
128
|
if (timeoutMs > 0) {
|
|
223
|
-
const timeout = new Promise<boolean>((_, reject) =>
|
|
224
|
-
setTimeout(() => reject(new Error("Timeout")), timeoutMs)
|
|
225
|
-
);
|
|
129
|
+
const timeout = new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs));
|
|
226
130
|
await Promise.race([SubscriptionManager.initialize(userId), timeout]);
|
|
227
131
|
} else {
|
|
228
132
|
await SubscriptionManager.initialize(userId);
|
|
229
133
|
}
|
|
230
134
|
} catch (error) {
|
|
231
|
-
|
|
232
|
-
if (__DEV__) {
|
|
233
|
-
console.warn('[SubscriptionInitializer] Initialize timeout/error (non-critical):', error);
|
|
234
|
-
}
|
|
135
|
+
if (__DEV__) console.warn('[SubscriptionInitializer] Initialize timeout/error (non-critical):', error);
|
|
235
136
|
}
|
|
236
137
|
|
|
237
138
|
configureAuthProvider({
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Initializer Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CreditsConfig } from "../../domain/entities/Credits";
|
|
6
|
+
|
|
7
|
+
export interface FirebaseAuthLike {
|
|
8
|
+
currentUser: { uid: string; isAnonymous: boolean } | null;
|
|
9
|
+
onAuthStateChanged: (callback: (user: { uid: string; isAnonymous: boolean } | null) => void) => () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CreditPackageConfig {
|
|
13
|
+
identifierPattern?: string;
|
|
14
|
+
amounts?: Record<string, number>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SubscriptionInitConfig {
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
apiKeyIos?: string;
|
|
20
|
+
apiKeyAndroid?: string;
|
|
21
|
+
entitlementId: string;
|
|
22
|
+
credits: CreditsConfig;
|
|
23
|
+
getAnonymousUserId: () => Promise<string>;
|
|
24
|
+
getFirebaseAuth: () => FirebaseAuthLike | null;
|
|
25
|
+
showAuthModal: () => void;
|
|
26
|
+
onCreditsUpdated?: (userId: string) => void;
|
|
27
|
+
creditPackages?: CreditPackageConfig;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
authStateTimeoutMs?: number;
|
|
30
|
+
}
|
|
@@ -1,63 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Trial Service
|
|
3
|
-
* Handles device-based trial tracking to prevent abuse
|
|
4
|
-
* Uses persistent device ID that survives app reinstalls
|
|
2
|
+
* Trial Service - Device-based trial tracking to prevent abuse
|
|
5
3
|
*/
|
|
6
|
-
|
|
7
4
|
declare const __DEV__: boolean;
|
|
8
5
|
|
|
9
|
-
import {
|
|
10
|
-
doc,
|
|
11
|
-
getDoc,
|
|
12
|
-
setDoc,
|
|
13
|
-
serverTimestamp,
|
|
14
|
-
arrayUnion,
|
|
15
|
-
} from "firebase/firestore";
|
|
6
|
+
import { doc, getDoc, setDoc, serverTimestamp, arrayUnion } from "firebase/firestore";
|
|
16
7
|
import { getFirestore } from "@umituz/react-native-firebase";
|
|
17
8
|
import { PersistentDeviceIdService } from "@umituz/react-native-design-system";
|
|
9
|
+
import type { TrialEligibilityResult } from "./TrialTypes";
|
|
18
10
|
|
|
19
|
-
|
|
11
|
+
export { TRIAL_CONFIG, type DeviceTrialRecord, type TrialEligibilityResult } from "./TrialTypes";
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
export const TRIAL_CONFIG = {
|
|
23
|
-
DURATION_DAYS: 3,
|
|
24
|
-
CREDITS: 5,
|
|
25
|
-
} as const;
|
|
26
|
-
|
|
27
|
-
/** Device trial record in Firestore */
|
|
28
|
-
export interface DeviceTrialRecord {
|
|
29
|
-
deviceId: string;
|
|
30
|
-
hasUsedTrial: boolean;
|
|
31
|
-
trialInProgress?: boolean;
|
|
32
|
-
trialStartedAt?: Date;
|
|
33
|
-
trialEndedAt?: Date;
|
|
34
|
-
trialConvertedAt?: Date;
|
|
35
|
-
lastUserId?: string;
|
|
36
|
-
userIds: string[];
|
|
37
|
-
createdAt: Date;
|
|
38
|
-
updatedAt: Date;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Trial eligibility result */
|
|
42
|
-
export interface TrialEligibilityResult {
|
|
43
|
-
eligible: boolean;
|
|
44
|
-
reason?: "already_used" | "device_not_found" | "error";
|
|
45
|
-
deviceId?: string;
|
|
46
|
-
}
|
|
13
|
+
const DEVICE_TRIALS_COLLECTION = "device_trials";
|
|
47
14
|
|
|
48
|
-
/**
|
|
49
|
-
* Get persistent device ID
|
|
50
|
-
*/
|
|
15
|
+
/** Get persistent device ID */
|
|
51
16
|
export async function getDeviceId(): Promise<string> {
|
|
52
17
|
return PersistentDeviceIdService.getDeviceId();
|
|
53
18
|
}
|
|
54
19
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
*/
|
|
58
|
-
export async function checkTrialEligibility(
|
|
59
|
-
deviceId?: string
|
|
60
|
-
): Promise<TrialEligibilityResult> {
|
|
20
|
+
/** Check if device is eligible for trial */
|
|
21
|
+
export async function checkTrialEligibility(deviceId?: string): Promise<TrialEligibilityResult> {
|
|
61
22
|
try {
|
|
62
23
|
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
63
24
|
const db = getFirestore();
|
|
@@ -110,13 +71,8 @@ export async function checkTrialEligibility(
|
|
|
110
71
|
}
|
|
111
72
|
}
|
|
112
73
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
*/
|
|
116
|
-
export async function recordTrialStart(
|
|
117
|
-
userId: string,
|
|
118
|
-
deviceId?: string
|
|
119
|
-
): Promise<boolean> {
|
|
74
|
+
/** Record trial start for a device */
|
|
75
|
+
export async function recordTrialStart(userId: string, deviceId?: string): Promise<boolean> {
|
|
120
76
|
try {
|
|
121
77
|
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
122
78
|
const db = getFirestore();
|
|
@@ -134,8 +90,6 @@ export async function recordTrialStart(
|
|
|
134
90
|
trialRef,
|
|
135
91
|
{
|
|
136
92
|
deviceId: effectiveDeviceId,
|
|
137
|
-
// Don't set hasUsedTrial here - only set when trial ends or converts
|
|
138
|
-
// This allows retry if user cancels before trial period ends
|
|
139
93
|
trialInProgress: true,
|
|
140
94
|
trialStartedAt: serverTimestamp(),
|
|
141
95
|
lastUserId: userId,
|
|
@@ -174,23 +128,18 @@ export async function recordTrialStart(
|
|
|
174
128
|
/**
|
|
175
129
|
* Record trial end (cancelled or expired)
|
|
176
130
|
*/
|
|
177
|
-
export async function recordTrialEnd(
|
|
178
|
-
deviceId?: string
|
|
179
|
-
): Promise<boolean> {
|
|
131
|
+
export async function recordTrialEnd(deviceId?: string): Promise<boolean> {
|
|
180
132
|
try {
|
|
181
133
|
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
182
134
|
const db = getFirestore();
|
|
183
135
|
|
|
184
|
-
if (!db)
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
136
|
+
if (!db) return false;
|
|
187
137
|
|
|
188
138
|
const trialRef = doc(db, DEVICE_TRIALS_COLLECTION, effectiveDeviceId);
|
|
189
139
|
|
|
190
140
|
await setDoc(
|
|
191
141
|
trialRef,
|
|
192
142
|
{
|
|
193
|
-
// Mark trial as used when it ends (prevents retry)
|
|
194
143
|
hasUsedTrial: true,
|
|
195
144
|
trialInProgress: false,
|
|
196
145
|
trialEndedAt: serverTimestamp(),
|
|
@@ -215,23 +164,18 @@ export async function recordTrialEnd(
|
|
|
215
164
|
/**
|
|
216
165
|
* Record trial conversion to paid subscription
|
|
217
166
|
*/
|
|
218
|
-
export async function recordTrialConversion(
|
|
219
|
-
deviceId?: string
|
|
220
|
-
): Promise<boolean> {
|
|
167
|
+
export async function recordTrialConversion(deviceId?: string): Promise<boolean> {
|
|
221
168
|
try {
|
|
222
169
|
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
223
170
|
const db = getFirestore();
|
|
224
171
|
|
|
225
|
-
if (!db)
|
|
226
|
-
return false;
|
|
227
|
-
}
|
|
172
|
+
if (!db) return false;
|
|
228
173
|
|
|
229
174
|
const trialRef = doc(db, DEVICE_TRIALS_COLLECTION, effectiveDeviceId);
|
|
230
175
|
|
|
231
176
|
await setDoc(
|
|
232
177
|
trialRef,
|
|
233
178
|
{
|
|
234
|
-
// Mark trial as used after conversion (prevents retry)
|
|
235
179
|
hasUsedTrial: true,
|
|
236
180
|
trialInProgress: false,
|
|
237
181
|
trialConvertedAt: serverTimestamp(),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trial Types and Constants
|
|
3
|
+
* Device-based trial tracking types
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Trial constants */
|
|
7
|
+
export const TRIAL_CONFIG = {
|
|
8
|
+
DURATION_DAYS: 3,
|
|
9
|
+
CREDITS: 5,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
/** Device trial record in Firestore */
|
|
13
|
+
export interface DeviceTrialRecord {
|
|
14
|
+
deviceId: string;
|
|
15
|
+
hasUsedTrial: boolean;
|
|
16
|
+
trialInProgress?: boolean;
|
|
17
|
+
trialStartedAt?: Date;
|
|
18
|
+
trialEndedAt?: Date;
|
|
19
|
+
trialConvertedAt?: Date;
|
|
20
|
+
lastUserId?: string;
|
|
21
|
+
userIds: string[];
|
|
22
|
+
createdAt: Date;
|
|
23
|
+
updatedAt: Date;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Trial eligibility result */
|
|
27
|
+
export interface TrialEligibilityResult {
|
|
28
|
+
eligible: boolean;
|
|
29
|
+
reason?: "already_used" | "device_not_found" | "error";
|
|
30
|
+
deviceId?: string;
|
|
31
|
+
}
|