@umituz/react-native-subscription 2.24.16 → 2.25.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/domain/entities/Credits.ts +12 -1
- package/src/domain/entities/SubscriptionStatus.ts +9 -0
- package/src/index.ts +11 -1
- package/src/infrastructure/mappers/CreditsMapper.ts +28 -4
- package/src/infrastructure/models/UserCreditsDocument.ts +12 -1
- package/src/infrastructure/repositories/CreditsRepository.ts +3 -0
- package/src/infrastructure/services/CreditsInitializer.ts +53 -2
- package/src/infrastructure/services/SubscriptionInitializer.ts +50 -2
- package/src/infrastructure/services/TrialService.ts +241 -0
- package/src/presentation/components/details/PremiumStatusBadge.tsx +10 -0
- package/src/presentation/hooks/index.ts +1 -0
- package/src/presentation/hooks/useSubscriptionSettingsConfig.ts +5 -2
- package/src/presentation/hooks/useSubscriptionSettingsConfig.utils.ts +10 -3
- package/src/presentation/hooks/useTrialEligibility.ts +66 -0
- package/src/presentation/types/SubscriptionDetailTypes.ts +6 -0
- package/src/presentation/types/SubscriptionSettingsTypes.ts +4 -0
- package/src/revenuecat/domain/value-objects/RevenueCatConfig.ts +4 -1
- package/src/revenuecat/infrastructure/utils/PremiumStatusSyncer.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.25.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",
|
|
@@ -19,7 +19,10 @@ export type PurchaseSource =
|
|
|
19
19
|
|
|
20
20
|
export type PurchaseType = "initial" | "renewal" | "upgrade" | "downgrade";
|
|
21
21
|
|
|
22
|
-
export type SubscriptionStatus = "active" | "expired" | "canceled" | "free";
|
|
22
|
+
export type SubscriptionStatus = "active" | "trial" | "trial_canceled" | "expired" | "canceled" | "free";
|
|
23
|
+
|
|
24
|
+
/** RevenueCat period types */
|
|
25
|
+
export type PeriodType = "NORMAL" | "INTRO" | "TRIAL";
|
|
23
26
|
|
|
24
27
|
/** Single Source of Truth for user subscription + credits data */
|
|
25
28
|
export interface UserCredits {
|
|
@@ -38,6 +41,14 @@ export interface UserCredits {
|
|
|
38
41
|
packageType?: "weekly" | "monthly" | "yearly" | "lifetime";
|
|
39
42
|
originalTransactionId?: string;
|
|
40
43
|
|
|
44
|
+
// Trial fields
|
|
45
|
+
periodType?: PeriodType;
|
|
46
|
+
isTrialing?: boolean;
|
|
47
|
+
trialStartDate?: Date | null;
|
|
48
|
+
trialEndDate?: Date | null;
|
|
49
|
+
trialCredits?: number;
|
|
50
|
+
convertedFromTrial?: boolean;
|
|
51
|
+
|
|
41
52
|
// Credits
|
|
42
53
|
credits: number;
|
|
43
54
|
creditLimit?: number;
|
|
@@ -2,11 +2,16 @@ import { timezoneService } from "@umituz/react-native-design-system";
|
|
|
2
2
|
|
|
3
3
|
export const SUBSCRIPTION_STATUS = {
|
|
4
4
|
ACTIVE: 'active',
|
|
5
|
+
TRIAL: 'trial',
|
|
6
|
+
TRIAL_CANCELED: 'trial_canceled',
|
|
5
7
|
EXPIRED: 'expired',
|
|
6
8
|
CANCELED: 'canceled',
|
|
7
9
|
NONE: 'none',
|
|
8
10
|
} as const;
|
|
9
11
|
|
|
12
|
+
/** RevenueCat period types */
|
|
13
|
+
export type PeriodType = "NORMAL" | "INTRO" | "TRIAL";
|
|
14
|
+
|
|
10
15
|
export type SubscriptionStatusType = (typeof SUBSCRIPTION_STATUS)[keyof typeof SUBSCRIPTION_STATUS];
|
|
11
16
|
|
|
12
17
|
export interface SubscriptionStatus {
|
|
@@ -17,6 +22,10 @@ export interface SubscriptionStatus {
|
|
|
17
22
|
customerId?: string | null;
|
|
18
23
|
syncedAt?: string | null;
|
|
19
24
|
status?: SubscriptionStatusType;
|
|
25
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
26
|
+
periodType?: PeriodType;
|
|
27
|
+
/** Whether user is currently in trial period */
|
|
28
|
+
isTrialing?: boolean;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export const createDefaultSubscriptionStatus = (): SubscriptionStatus => ({
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export * from "./domains/config";
|
|
|
8
8
|
|
|
9
9
|
// Domain Layer
|
|
10
10
|
export { createDefaultSubscriptionStatus, isSubscriptionValid } from "./domain/entities/SubscriptionStatus";
|
|
11
|
-
export type { SubscriptionStatus, SubscriptionStatusType } from "./domain/entities/SubscriptionStatus";
|
|
11
|
+
export type { SubscriptionStatus, SubscriptionStatusType, PeriodType } from "./domain/entities/SubscriptionStatus";
|
|
12
12
|
export type { SubscriptionConfig } from "./domain/value-objects/SubscriptionConfig";
|
|
13
13
|
export type { ISubscriptionRepository } from "./application/ports/ISubscriptionRepository";
|
|
14
14
|
|
|
@@ -22,6 +22,16 @@ export {
|
|
|
22
22
|
type FeedbackSubmitResult,
|
|
23
23
|
} from "./infrastructure/services/FeedbackService";
|
|
24
24
|
export { initializeSubscription, type SubscriptionInitConfig, type CreditPackageConfig } from "./infrastructure/services/SubscriptionInitializer";
|
|
25
|
+
export {
|
|
26
|
+
getDeviceId,
|
|
27
|
+
checkTrialEligibility,
|
|
28
|
+
recordTrialStart,
|
|
29
|
+
recordTrialEnd,
|
|
30
|
+
recordTrialConversion,
|
|
31
|
+
TRIAL_CONFIG,
|
|
32
|
+
type DeviceTrialRecord,
|
|
33
|
+
type TrialEligibilityResult,
|
|
34
|
+
} from "./infrastructure/services/TrialService";
|
|
25
35
|
export { CreditsRepository, createCreditsRepository } from "./infrastructure/repositories/CreditsRepository";
|
|
26
36
|
export { configureCreditsRepository, getCreditsRepository, getCreditsConfig, resetCreditsRepository, isCreditsRepositoryConfigured } from "./infrastructure/repositories/CreditsRepositoryProvider";
|
|
27
37
|
export {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type { UserCredits, SubscriptionStatus } from "../../domain/entities/Credits";
|
|
1
|
+
import type { UserCredits, SubscriptionStatus, PeriodType } from "../../domain/entities/Credits";
|
|
2
2
|
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
3
3
|
|
|
4
4
|
/** Maps Firestore document to domain entity with expiration validation */
|
|
5
5
|
export class CreditsMapper {
|
|
6
6
|
static toEntity(doc: UserCreditsDocumentRead): UserCredits {
|
|
7
7
|
const expirationDate = doc.expirationDate?.toDate?.() ?? null;
|
|
8
|
+
const periodType = doc.periodType as PeriodType | undefined;
|
|
8
9
|
|
|
9
10
|
// Validate isPremium against expirationDate (real-time check)
|
|
10
|
-
const { isPremium, status } = CreditsMapper.validateSubscription(doc, expirationDate);
|
|
11
|
+
const { isPremium, status } = CreditsMapper.validateSubscription(doc, expirationDate, periodType);
|
|
11
12
|
|
|
12
13
|
return {
|
|
13
14
|
// Core subscription (validated)
|
|
@@ -25,6 +26,14 @@ export class CreditsMapper {
|
|
|
25
26
|
packageType: doc.packageType,
|
|
26
27
|
originalTransactionId: doc.originalTransactionId,
|
|
27
28
|
|
|
29
|
+
// Trial fields
|
|
30
|
+
periodType,
|
|
31
|
+
isTrialing: doc.isTrialing,
|
|
32
|
+
trialStartDate: doc.trialStartDate?.toDate?.() ?? null,
|
|
33
|
+
trialEndDate: doc.trialEndDate?.toDate?.() ?? null,
|
|
34
|
+
trialCredits: doc.trialCredits,
|
|
35
|
+
convertedFromTrial: doc.convertedFromTrial,
|
|
36
|
+
|
|
28
37
|
// Credits
|
|
29
38
|
credits: doc.credits,
|
|
30
39
|
creditLimit: doc.creditLimit,
|
|
@@ -37,12 +46,14 @@ export class CreditsMapper {
|
|
|
37
46
|
};
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
/** Validate subscription status against expirationDate */
|
|
49
|
+
/** Validate subscription status against expirationDate and periodType */
|
|
41
50
|
private static validateSubscription(
|
|
42
51
|
doc: UserCreditsDocumentRead,
|
|
43
|
-
expirationDate: Date | null
|
|
52
|
+
expirationDate: Date | null,
|
|
53
|
+
periodType?: PeriodType
|
|
44
54
|
): { isPremium: boolean; status: SubscriptionStatus } {
|
|
45
55
|
const docIsPremium = doc.isPremium ?? false;
|
|
56
|
+
const willRenew = doc.willRenew ?? false;
|
|
46
57
|
|
|
47
58
|
// No expiration date = lifetime or free
|
|
48
59
|
if (!expirationDate) {
|
|
@@ -60,6 +71,19 @@ export class CreditsMapper {
|
|
|
60
71
|
return { isPremium: false, status: "expired" };
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
// Handle trial period
|
|
75
|
+
if (periodType === "TRIAL") {
|
|
76
|
+
return {
|
|
77
|
+
isPremium: docIsPremium,
|
|
78
|
+
status: willRenew === false ? "trial_canceled" : "trial",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle canceled subscription (will not renew but still active)
|
|
83
|
+
if (docIsPremium && willRenew === false) {
|
|
84
|
+
return { isPremium: true, status: "canceled" };
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
// Subscription still active
|
|
64
88
|
return {
|
|
65
89
|
isPremium: docIsPremium,
|
|
@@ -12,7 +12,10 @@ export type PurchaseSource =
|
|
|
12
12
|
|
|
13
13
|
export type PurchaseType = "initial" | "renewal" | "upgrade" | "downgrade";
|
|
14
14
|
|
|
15
|
-
export type SubscriptionDocStatus = "active" | "expired" | "canceled" | "free";
|
|
15
|
+
export type SubscriptionDocStatus = "active" | "trial" | "trial_canceled" | "expired" | "canceled" | "free";
|
|
16
|
+
|
|
17
|
+
/** RevenueCat period types */
|
|
18
|
+
export type PeriodType = "NORMAL" | "INTRO" | "TRIAL";
|
|
16
19
|
|
|
17
20
|
export interface PurchaseMetadata {
|
|
18
21
|
productId: string;
|
|
@@ -43,6 +46,14 @@ export interface UserCreditsDocumentRead {
|
|
|
43
46
|
packageType?: "weekly" | "monthly" | "yearly" | "lifetime";
|
|
44
47
|
originalTransactionId?: string;
|
|
45
48
|
|
|
49
|
+
// Trial fields
|
|
50
|
+
periodType?: PeriodType;
|
|
51
|
+
isTrialing?: boolean;
|
|
52
|
+
trialStartDate?: FirestoreTimestamp;
|
|
53
|
+
trialEndDate?: FirestoreTimestamp;
|
|
54
|
+
trialCredits?: number;
|
|
55
|
+
convertedFromTrial?: boolean;
|
|
56
|
+
|
|
46
57
|
// Credits
|
|
47
58
|
credits: number;
|
|
48
59
|
creditLimit?: number;
|
|
@@ -20,6 +20,8 @@ export interface RevenueCatData {
|
|
|
20
20
|
willRenew?: boolean;
|
|
21
21
|
originalTransactionId?: string;
|
|
22
22
|
isPremium?: boolean;
|
|
23
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
24
|
+
periodType?: "NORMAL" | "INTRO" | "TRIAL";
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export class CreditsRepository extends BaseRepository {
|
|
@@ -84,6 +86,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
84
86
|
willRenew: revenueCatData?.willRenew,
|
|
85
87
|
originalTransactionId: revenueCatData?.originalTransactionId,
|
|
86
88
|
isPremium: revenueCatData?.isPremium,
|
|
89
|
+
periodType: revenueCatData?.periodType,
|
|
87
90
|
};
|
|
88
91
|
|
|
89
92
|
const res = await initializeCreditsTransaction(
|
|
@@ -16,7 +16,9 @@ import type {
|
|
|
16
16
|
PurchaseType,
|
|
17
17
|
PurchaseMetadata,
|
|
18
18
|
SubscriptionDocStatus,
|
|
19
|
+
PeriodType,
|
|
19
20
|
} from "../models/UserCreditsDocument";
|
|
21
|
+
import { TRIAL_CONFIG } from "./TrialService";
|
|
20
22
|
import { detectPackageType } from "../../utils/packageTypeDetector";
|
|
21
23
|
import { getCreditAllocation } from "../../utils/creditMapper";
|
|
22
24
|
|
|
@@ -34,6 +36,8 @@ export interface InitializeCreditsMetadata {
|
|
|
34
36
|
willRenew?: boolean;
|
|
35
37
|
originalTransactionId?: string;
|
|
36
38
|
isPremium?: boolean;
|
|
39
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
40
|
+
periodType?: PeriodType;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export async function initializeCreditsTransaction(
|
|
@@ -122,9 +126,36 @@ export async function initializeCreditsTransaction(
|
|
|
122
126
|
? [...(existing?.purchaseHistory || []), purchaseMetadata].slice(-10)
|
|
123
127
|
: existing?.purchaseHistory;
|
|
124
128
|
|
|
125
|
-
// Determine subscription status
|
|
129
|
+
// Determine subscription status based on isPremium, willRenew, and periodType
|
|
126
130
|
const isPremium = metadata?.isPremium ?? true;
|
|
127
|
-
const
|
|
131
|
+
const willRenew = metadata?.willRenew;
|
|
132
|
+
const periodType = metadata?.periodType;
|
|
133
|
+
const isTrialing = periodType === "TRIAL";
|
|
134
|
+
|
|
135
|
+
// Status logic:
|
|
136
|
+
// - trial: periodType is TRIAL and premium
|
|
137
|
+
// - trial_canceled: periodType is TRIAL and premium but willRenew=false
|
|
138
|
+
// - canceled: premium but willRenew=false (non-trial)
|
|
139
|
+
// - expired: not premium
|
|
140
|
+
// - active: premium and will renew (non-trial)
|
|
141
|
+
let status: SubscriptionDocStatus;
|
|
142
|
+
if (!isPremium) {
|
|
143
|
+
status = "expired";
|
|
144
|
+
} else if (isTrialing) {
|
|
145
|
+
status = willRenew === false ? "trial_canceled" : "trial";
|
|
146
|
+
} else if (willRenew === false) {
|
|
147
|
+
status = "canceled";
|
|
148
|
+
} else {
|
|
149
|
+
status = "active";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Determine credits based on status
|
|
153
|
+
// Trial: 5 credits, Trial canceled: 0 credits, Normal: plan-based credits
|
|
154
|
+
if (status === "trial") {
|
|
155
|
+
newCredits = TRIAL_CONFIG.CREDITS;
|
|
156
|
+
} else if (status === "trial_canceled") {
|
|
157
|
+
newCredits = 0;
|
|
158
|
+
}
|
|
128
159
|
|
|
129
160
|
// Build credits data (Single Source of Truth)
|
|
130
161
|
const creditsData: Record<string, unknown> = {
|
|
@@ -166,6 +197,26 @@ export async function initializeCreditsTransaction(
|
|
|
166
197
|
creditsData.appVersion = appVersion;
|
|
167
198
|
}
|
|
168
199
|
|
|
200
|
+
// Trial-specific fields
|
|
201
|
+
if (periodType) {
|
|
202
|
+
creditsData.periodType = periodType;
|
|
203
|
+
}
|
|
204
|
+
if (isTrialing) {
|
|
205
|
+
creditsData.isTrialing = true;
|
|
206
|
+
creditsData.trialCredits = TRIAL_CONFIG.CREDITS;
|
|
207
|
+
// Set trial dates if this is a new trial
|
|
208
|
+
if (!existing?.trialStartDate) {
|
|
209
|
+
creditsData.trialStartDate = now;
|
|
210
|
+
}
|
|
211
|
+
if (metadata?.expirationDate) {
|
|
212
|
+
creditsData.trialEndDate = Timestamp.fromDate(new Date(metadata.expirationDate));
|
|
213
|
+
}
|
|
214
|
+
} else if (existing?.isTrialing && !isTrialing && isPremium) {
|
|
215
|
+
// User converted from trial to paid
|
|
216
|
+
creditsData.isTrialing = false;
|
|
217
|
+
creditsData.convertedFromTrial = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
169
220
|
// Purchase metadata
|
|
170
221
|
if (metadata?.source) {
|
|
171
222
|
creditsData.purchaseSource = metadata.source;
|
|
@@ -51,6 +51,7 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
|
|
|
51
51
|
willRenew: entitlement?.willRenew ?? false,
|
|
52
52
|
originalTransactionId: entitlement?.originalPurchaseDate ?? undefined,
|
|
53
53
|
isPremium: Object.keys(customerInfo.entitlements.active).length > 0,
|
|
54
|
+
periodType: entitlement?.periodType as "NORMAL" | "INTRO" | "TRIAL" | undefined,
|
|
54
55
|
};
|
|
55
56
|
};
|
|
56
57
|
|
|
@@ -104,9 +105,56 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
|
|
|
104
105
|
}
|
|
105
106
|
};
|
|
106
107
|
|
|
108
|
+
/** Sync premium status changes (including cancellation) to Firestore */
|
|
109
|
+
const onPremiumStatusChanged = async (
|
|
110
|
+
userId: string,
|
|
111
|
+
isPremium: boolean,
|
|
112
|
+
productId?: string,
|
|
113
|
+
expiresAt?: string,
|
|
114
|
+
willRenew?: boolean,
|
|
115
|
+
periodType?: "NORMAL" | "INTRO" | "TRIAL"
|
|
116
|
+
) => {
|
|
117
|
+
if (__DEV__) {
|
|
118
|
+
console.log('[SubscriptionInitializer] onPremiumStatusChanged:', { userId, isPremium, productId, willRenew, periodType });
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const revenueCatData: RevenueCatData = {
|
|
122
|
+
expirationDate: expiresAt ?? null,
|
|
123
|
+
willRenew: willRenew ?? false,
|
|
124
|
+
isPremium,
|
|
125
|
+
periodType,
|
|
126
|
+
};
|
|
127
|
+
await getCreditsRepository().initializeCredits(
|
|
128
|
+
userId,
|
|
129
|
+
`status_sync_${Date.now()}`,
|
|
130
|
+
productId,
|
|
131
|
+
"settings" as any,
|
|
132
|
+
revenueCatData
|
|
133
|
+
);
|
|
134
|
+
if (__DEV__) {
|
|
135
|
+
console.log('[SubscriptionInitializer] Premium status synced to Firestore');
|
|
136
|
+
}
|
|
137
|
+
onCreditsUpdated?.(userId);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (__DEV__) {
|
|
140
|
+
console.error('[SubscriptionInitializer] Premium status sync failed:', error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
107
145
|
SubscriptionManager.configure({
|
|
108
|
-
config: {
|
|
109
|
-
|
|
146
|
+
config: {
|
|
147
|
+
apiKey: key,
|
|
148
|
+
testStoreKey,
|
|
149
|
+
entitlementIdentifier: entitlementId,
|
|
150
|
+
consumableProductIdentifiers: [creditPackages?.identifierPattern || "credit"],
|
|
151
|
+
onPurchaseCompleted: onPurchase,
|
|
152
|
+
onRenewalDetected: onRenewal,
|
|
153
|
+
onPremiumStatusChanged,
|
|
154
|
+
onCreditsUpdated,
|
|
155
|
+
},
|
|
156
|
+
apiKey: key,
|
|
157
|
+
getAnonymousUserId,
|
|
110
158
|
});
|
|
111
159
|
|
|
112
160
|
const userId = await waitForAuthState(getFirebaseAuth, authStateTimeoutMs);
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trial Service
|
|
3
|
+
* Handles device-based trial tracking to prevent abuse
|
|
4
|
+
* Uses persistent device ID that survives app reinstalls
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
declare const __DEV__: boolean;
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
doc,
|
|
11
|
+
getDoc,
|
|
12
|
+
setDoc,
|
|
13
|
+
serverTimestamp,
|
|
14
|
+
arrayUnion,
|
|
15
|
+
} from "firebase/firestore";
|
|
16
|
+
import { getFirestore } from "@umituz/react-native-firebase";
|
|
17
|
+
import { PersistentDeviceIdService } from "@umituz/react-native-design-system";
|
|
18
|
+
|
|
19
|
+
const DEVICE_TRIALS_COLLECTION = "device_trials";
|
|
20
|
+
|
|
21
|
+
/** Trial constants */
|
|
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
|
+
trialStartedAt?: Date;
|
|
32
|
+
trialEndedAt?: Date;
|
|
33
|
+
trialConvertedAt?: Date;
|
|
34
|
+
lastUserId?: string;
|
|
35
|
+
userIds: string[];
|
|
36
|
+
createdAt: Date;
|
|
37
|
+
updatedAt: Date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Trial eligibility result */
|
|
41
|
+
export interface TrialEligibilityResult {
|
|
42
|
+
eligible: boolean;
|
|
43
|
+
reason?: "already_used" | "device_not_found" | "error";
|
|
44
|
+
deviceId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get persistent device ID
|
|
49
|
+
*/
|
|
50
|
+
export async function getDeviceId(): Promise<string> {
|
|
51
|
+
return PersistentDeviceIdService.getDeviceId();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if device is eligible for trial
|
|
56
|
+
*/
|
|
57
|
+
export async function checkTrialEligibility(
|
|
58
|
+
deviceId?: string
|
|
59
|
+
): Promise<TrialEligibilityResult> {
|
|
60
|
+
try {
|
|
61
|
+
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
62
|
+
const db = getFirestore();
|
|
63
|
+
|
|
64
|
+
if (!db) {
|
|
65
|
+
if (__DEV__) {
|
|
66
|
+
console.log("[TrialService] No Firestore instance");
|
|
67
|
+
}
|
|
68
|
+
return { eligible: true, deviceId: effectiveDeviceId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const trialRef = doc(db, DEVICE_TRIALS_COLLECTION, effectiveDeviceId);
|
|
72
|
+
const trialDoc = await getDoc(trialRef);
|
|
73
|
+
|
|
74
|
+
if (!trialDoc.exists()) {
|
|
75
|
+
if (__DEV__) {
|
|
76
|
+
console.log("[TrialService] No trial record found, eligible");
|
|
77
|
+
}
|
|
78
|
+
return { eligible: true, deviceId: effectiveDeviceId };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const data = trialDoc.data();
|
|
82
|
+
const hasUsedTrial = data?.hasUsedTrial === true;
|
|
83
|
+
|
|
84
|
+
if (__DEV__) {
|
|
85
|
+
console.log("[TrialService] Trial record found:", {
|
|
86
|
+
deviceId: effectiveDeviceId.slice(0, 8),
|
|
87
|
+
hasUsedTrial,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (hasUsedTrial) {
|
|
92
|
+
return {
|
|
93
|
+
eligible: false,
|
|
94
|
+
reason: "already_used",
|
|
95
|
+
deviceId: effectiveDeviceId,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { eligible: true, deviceId: effectiveDeviceId };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (__DEV__) {
|
|
102
|
+
console.error("[TrialService] Eligibility check error:", error);
|
|
103
|
+
}
|
|
104
|
+
return { eligible: true, reason: "error" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Record trial start for a device
|
|
110
|
+
*/
|
|
111
|
+
export async function recordTrialStart(
|
|
112
|
+
userId: string,
|
|
113
|
+
deviceId?: string
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
117
|
+
const db = getFirestore();
|
|
118
|
+
|
|
119
|
+
if (!db) {
|
|
120
|
+
if (__DEV__) {
|
|
121
|
+
console.log("[TrialService] No Firestore instance");
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const trialRef = doc(db, DEVICE_TRIALS_COLLECTION, effectiveDeviceId);
|
|
127
|
+
|
|
128
|
+
await setDoc(
|
|
129
|
+
trialRef,
|
|
130
|
+
{
|
|
131
|
+
deviceId: effectiveDeviceId,
|
|
132
|
+
hasUsedTrial: true,
|
|
133
|
+
trialStartedAt: serverTimestamp(),
|
|
134
|
+
lastUserId: userId,
|
|
135
|
+
userIds: arrayUnion(userId),
|
|
136
|
+
updatedAt: serverTimestamp(),
|
|
137
|
+
},
|
|
138
|
+
{ merge: true }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Also set createdAt if it's a new record
|
|
142
|
+
const existingDoc = await getDoc(trialRef);
|
|
143
|
+
if (!existingDoc.data()?.createdAt) {
|
|
144
|
+
await setDoc(
|
|
145
|
+
trialRef,
|
|
146
|
+
{ createdAt: serverTimestamp() },
|
|
147
|
+
{ merge: true }
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (__DEV__) {
|
|
152
|
+
console.log("[TrialService] Trial recorded:", {
|
|
153
|
+
deviceId: effectiveDeviceId.slice(0, 8),
|
|
154
|
+
userId: userId.slice(0, 8),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (__DEV__) {
|
|
161
|
+
console.error("[TrialService] Record trial error:", error);
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Record trial end (cancelled or expired)
|
|
169
|
+
*/
|
|
170
|
+
export async function recordTrialEnd(
|
|
171
|
+
deviceId?: string
|
|
172
|
+
): Promise<boolean> {
|
|
173
|
+
try {
|
|
174
|
+
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
175
|
+
const db = getFirestore();
|
|
176
|
+
|
|
177
|
+
if (!db) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const trialRef = doc(db, DEVICE_TRIALS_COLLECTION, effectiveDeviceId);
|
|
182
|
+
|
|
183
|
+
await setDoc(
|
|
184
|
+
trialRef,
|
|
185
|
+
{
|
|
186
|
+
trialEndedAt: serverTimestamp(),
|
|
187
|
+
updatedAt: serverTimestamp(),
|
|
188
|
+
},
|
|
189
|
+
{ merge: true }
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (__DEV__) {
|
|
193
|
+
console.log("[TrialService] Trial end recorded");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return true;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (__DEV__) {
|
|
199
|
+
console.error("[TrialService] Record trial end error:", error);
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Record trial conversion to paid subscription
|
|
207
|
+
*/
|
|
208
|
+
export async function recordTrialConversion(
|
|
209
|
+
deviceId?: string
|
|
210
|
+
): Promise<boolean> {
|
|
211
|
+
try {
|
|
212
|
+
const effectiveDeviceId = deviceId || await getDeviceId();
|
|
213
|
+
const db = getFirestore();
|
|
214
|
+
|
|
215
|
+
if (!db) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const trialRef = doc(db, DEVICE_TRIALS_COLLECTION, effectiveDeviceId);
|
|
220
|
+
|
|
221
|
+
await setDoc(
|
|
222
|
+
trialRef,
|
|
223
|
+
{
|
|
224
|
+
trialConvertedAt: serverTimestamp(),
|
|
225
|
+
updatedAt: serverTimestamp(),
|
|
226
|
+
},
|
|
227
|
+
{ merge: true }
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (__DEV__) {
|
|
231
|
+
console.log("[TrialService] Trial conversion recorded");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return true;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (__DEV__) {
|
|
237
|
+
console.error("[TrialService] Record conversion error:", error);
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -16,6 +16,10 @@ export interface PremiumStatusBadgeProps {
|
|
|
16
16
|
expiredLabel: string;
|
|
17
17
|
noneLabel: string;
|
|
18
18
|
canceledLabel: string;
|
|
19
|
+
/** Label for trial status (defaults to activeLabel if not provided) */
|
|
20
|
+
trialLabel?: string;
|
|
21
|
+
/** Label for trial_canceled status (defaults to canceledLabel if not provided) */
|
|
22
|
+
trialCanceledLabel?: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export const PremiumStatusBadge: React.FC<PremiumStatusBadgeProps> = ({
|
|
@@ -24,11 +28,15 @@ export const PremiumStatusBadge: React.FC<PremiumStatusBadgeProps> = ({
|
|
|
24
28
|
expiredLabel,
|
|
25
29
|
noneLabel,
|
|
26
30
|
canceledLabel,
|
|
31
|
+
trialLabel,
|
|
32
|
+
trialCanceledLabel,
|
|
27
33
|
}) => {
|
|
28
34
|
const tokens = useAppDesignTokens();
|
|
29
35
|
|
|
30
36
|
const labels: Record<SubscriptionStatusType, string> = {
|
|
31
37
|
active: activeLabel,
|
|
38
|
+
trial: trialLabel ?? activeLabel,
|
|
39
|
+
trial_canceled: trialCanceledLabel ?? canceledLabel,
|
|
32
40
|
expired: expiredLabel,
|
|
33
41
|
none: noneLabel,
|
|
34
42
|
canceled: canceledLabel,
|
|
@@ -37,6 +45,8 @@ export const PremiumStatusBadge: React.FC<PremiumStatusBadgeProps> = ({
|
|
|
37
45
|
const backgroundColor = useMemo(() => {
|
|
38
46
|
const colors: Record<SubscriptionStatusType, string> = {
|
|
39
47
|
active: tokens.colors.success,
|
|
48
|
+
trial: tokens.colors.primary, // Blue/purple for trial
|
|
49
|
+
trial_canceled: tokens.colors.warning, // Orange for trial canceled
|
|
40
50
|
expired: tokens.colors.error,
|
|
41
51
|
none: tokens.colors.textTertiary,
|
|
42
52
|
canceled: tokens.colors.warning,
|
|
@@ -20,5 +20,6 @@ export * from "./useSubscriptionSettingsConfig";
|
|
|
20
20
|
export * from "./useSubscriptionStatus";
|
|
21
21
|
export * from "./useUserTier";
|
|
22
22
|
export * from "./useUserTierWithRepository";
|
|
23
|
+
export * from "./useTrialEligibility";
|
|
23
24
|
export * from "./feedback/usePaywallFeedback";
|
|
24
25
|
export * from "./feedback/useFeedbackSubmit";
|
|
@@ -65,10 +65,13 @@ export const useSubscriptionSettingsConfig = (
|
|
|
65
65
|
// Days remaining
|
|
66
66
|
const daysRemaining = useMemo(() => calculateDaysRemaining(expiresAtIso), [expiresAtIso]);
|
|
67
67
|
|
|
68
|
-
//
|
|
68
|
+
// Period type from Firestore
|
|
69
|
+
const periodType = credits?.periodType;
|
|
70
|
+
|
|
71
|
+
// Status type: prioritize Firestore status, then derive from willRenew + expiration + periodType
|
|
69
72
|
const statusType: SubscriptionStatusType = credits?.status
|
|
70
73
|
? (credits.status as SubscriptionStatusType)
|
|
71
|
-
: getSubscriptionStatusType(isPremium, willRenew, expiresAtIso);
|
|
74
|
+
: getSubscriptionStatusType(isPremium, willRenew, expiresAtIso, periodType);
|
|
72
75
|
|
|
73
76
|
const creditsArray = useCreditsArray(credits, dynamicCreditLimit, translations);
|
|
74
77
|
|
|
@@ -37,16 +37,18 @@ export function useCreditsArray(
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Calculates subscription status type based on premium and
|
|
40
|
+
* Calculates subscription status type based on premium, renewal status, and period type
|
|
41
41
|
* @param isPremium - Whether user has premium subscription
|
|
42
42
|
* @param willRenew - Whether subscription will auto-renew (false = canceled)
|
|
43
43
|
* @param expiresAt - Expiration date ISO string (null for lifetime)
|
|
44
|
+
* @param periodType - RevenueCat period type: NORMAL, INTRO, or TRIAL
|
|
44
45
|
*/
|
|
45
46
|
export function getSubscriptionStatusType(
|
|
46
47
|
isPremium: boolean,
|
|
47
48
|
willRenew?: boolean,
|
|
48
|
-
expiresAt?: string | null
|
|
49
|
-
|
|
49
|
+
expiresAt?: string | null,
|
|
50
|
+
periodType?: "NORMAL" | "INTRO" | "TRIAL"
|
|
51
|
+
): "active" | "trial" | "trial_canceled" | "canceled" | "expired" | "none" {
|
|
50
52
|
if (!isPremium) {
|
|
51
53
|
return "none";
|
|
52
54
|
}
|
|
@@ -63,6 +65,11 @@ export function getSubscriptionStatusType(
|
|
|
63
65
|
return "expired";
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
// Trial period handling
|
|
69
|
+
if (periodType === "TRIAL") {
|
|
70
|
+
return willRenew === false ? "trial_canceled" : "trial";
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
// Premium with willRenew=false means subscription is canceled but still active until expiration
|
|
67
74
|
if (willRenew === false) {
|
|
68
75
|
return "canceled";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTrialEligibility Hook
|
|
3
|
+
* Checks if device is eligible for free trial
|
|
4
|
+
* Uses persistent device ID to prevent trial abuse
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useCallback } from "react";
|
|
8
|
+
import {
|
|
9
|
+
checkTrialEligibility,
|
|
10
|
+
getDeviceId,
|
|
11
|
+
} from "../../infrastructure/services/TrialService";
|
|
12
|
+
|
|
13
|
+
export interface UseTrialEligibilityResult {
|
|
14
|
+
/** Whether device is eligible for trial */
|
|
15
|
+
isEligible: boolean;
|
|
16
|
+
/** Whether eligibility check is in progress */
|
|
17
|
+
isLoading: boolean;
|
|
18
|
+
/** Reason why not eligible (if applicable) */
|
|
19
|
+
reason?: "already_used" | "device_not_found" | "error";
|
|
20
|
+
/** Device ID used for checking */
|
|
21
|
+
deviceId: string | null;
|
|
22
|
+
/** Refresh eligibility status */
|
|
23
|
+
refresh: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook to check trial eligibility based on device ID
|
|
28
|
+
* Device ID persists across app reinstalls via Keychain
|
|
29
|
+
*/
|
|
30
|
+
export function useTrialEligibility(): UseTrialEligibilityResult {
|
|
31
|
+
const [isEligible, setIsEligible] = useState(true);
|
|
32
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
33
|
+
const [reason, setReason] = useState<"already_used" | "device_not_found" | "error">();
|
|
34
|
+
const [deviceId, setDeviceId] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const checkEligibility = useCallback(async () => {
|
|
37
|
+
setIsLoading(true);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const id = await getDeviceId();
|
|
41
|
+
setDeviceId(id);
|
|
42
|
+
|
|
43
|
+
const result = await checkTrialEligibility(id);
|
|
44
|
+
setIsEligible(result.eligible);
|
|
45
|
+
setReason(result.reason);
|
|
46
|
+
} catch {
|
|
47
|
+
// On error, allow trial (better UX)
|
|
48
|
+
setIsEligible(true);
|
|
49
|
+
setReason("error");
|
|
50
|
+
} finally {
|
|
51
|
+
setIsLoading(false);
|
|
52
|
+
}
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
checkEligibility();
|
|
57
|
+
}, [checkEligibility]);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
isEligible,
|
|
61
|
+
isLoading,
|
|
62
|
+
reason,
|
|
63
|
+
deviceId,
|
|
64
|
+
refresh: checkEligibility,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -16,6 +16,10 @@ export interface SubscriptionDetailTranslations {
|
|
|
16
16
|
statusExpired: string;
|
|
17
17
|
statusFree: string;
|
|
18
18
|
statusCanceled: string;
|
|
19
|
+
/** Trial status label (defaults to statusActive if not provided) */
|
|
20
|
+
statusTrial?: string;
|
|
21
|
+
/** Trial canceled status label (defaults to statusCanceled if not provided) */
|
|
22
|
+
statusTrialCanceled?: string;
|
|
19
23
|
expiresLabel: string;
|
|
20
24
|
purchasedLabel: string;
|
|
21
25
|
lifetimeLabel: string;
|
|
@@ -100,6 +104,8 @@ export interface SubscriptionHeaderProps {
|
|
|
100
104
|
| "statusExpired"
|
|
101
105
|
| "statusFree"
|
|
102
106
|
| "statusCanceled"
|
|
107
|
+
| "statusTrial"
|
|
108
|
+
| "statusTrialCanceled"
|
|
103
109
|
| "expiresLabel"
|
|
104
110
|
| "purchasedLabel"
|
|
105
111
|
| "lifetimeLabel"
|
|
@@ -42,6 +42,10 @@ export interface SubscriptionSettingsTranslations {
|
|
|
42
42
|
statusFree: string;
|
|
43
43
|
statusExpired: string;
|
|
44
44
|
statusCanceled: string;
|
|
45
|
+
/** Trial status label (defaults to statusActive if not provided) */
|
|
46
|
+
statusTrial?: string;
|
|
47
|
+
/** Trial canceled status label (defaults to statusCanceled if not provided) */
|
|
48
|
+
statusTrialCanceled?: string;
|
|
45
49
|
/** Detail screen translations */
|
|
46
50
|
statusLabel: string;
|
|
47
51
|
expiresLabel: string;
|
|
@@ -19,7 +19,10 @@ export interface RevenueCatConfig {
|
|
|
19
19
|
userId: string,
|
|
20
20
|
isPremium: boolean,
|
|
21
21
|
productId?: string,
|
|
22
|
-
expiresAt?: string
|
|
22
|
+
expiresAt?: string,
|
|
23
|
+
willRenew?: boolean,
|
|
24
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
25
|
+
periodType?: "NORMAL" | "INTRO" | "TRIAL"
|
|
23
26
|
) => Promise<void> | void;
|
|
24
27
|
/** Callback for purchase completion */
|
|
25
28
|
onPurchaseCompleted?: (
|
|
@@ -27,10 +27,12 @@ export async function syncPremiumStatus(
|
|
|
27
27
|
userId,
|
|
28
28
|
true,
|
|
29
29
|
premiumEntitlement.productIdentifier,
|
|
30
|
-
premiumEntitlement.expirationDate ?? undefined
|
|
30
|
+
premiumEntitlement.expirationDate ?? undefined,
|
|
31
|
+
premiumEntitlement.willRenew,
|
|
32
|
+
premiumEntitlement.periodType as "NORMAL" | "INTRO" | "TRIAL" | undefined
|
|
31
33
|
);
|
|
32
34
|
} else {
|
|
33
|
-
await config.onPremiumStatusChanged(userId, false);
|
|
35
|
+
await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined, undefined);
|
|
34
36
|
}
|
|
35
37
|
} catch {
|
|
36
38
|
// Silent error handling
|