@umituz/react-native-subscription 2.24.17 → 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 +44 -3
- package/src/infrastructure/services/SubscriptionInitializer.ts +5 -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 +3 -1
- package/src/revenuecat/infrastructure/utils/PremiumStatusSyncer.ts +3 -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,20 +126,37 @@ export async function initializeCreditsTransaction(
|
|
|
122
126
|
? [...(existing?.purchaseHistory || []), purchaseMetadata].slice(-10)
|
|
123
127
|
: existing?.purchaseHistory;
|
|
124
128
|
|
|
125
|
-
// Determine subscription status based on isPremium and
|
|
129
|
+
// Determine subscription status based on isPremium, willRenew, and periodType
|
|
126
130
|
const isPremium = metadata?.isPremium ?? true;
|
|
127
131
|
const willRenew = metadata?.willRenew;
|
|
128
|
-
|
|
129
|
-
|
|
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)
|
|
130
141
|
let status: SubscriptionDocStatus;
|
|
131
142
|
if (!isPremium) {
|
|
132
143
|
status = "expired";
|
|
144
|
+
} else if (isTrialing) {
|
|
145
|
+
status = willRenew === false ? "trial_canceled" : "trial";
|
|
133
146
|
} else if (willRenew === false) {
|
|
134
147
|
status = "canceled";
|
|
135
148
|
} else {
|
|
136
149
|
status = "active";
|
|
137
150
|
}
|
|
138
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
|
+
}
|
|
159
|
+
|
|
139
160
|
// Build credits data (Single Source of Truth)
|
|
140
161
|
const creditsData: Record<string, unknown> = {
|
|
141
162
|
// Core subscription
|
|
@@ -176,6 +197,26 @@ export async function initializeCreditsTransaction(
|
|
|
176
197
|
creditsData.appVersion = appVersion;
|
|
177
198
|
}
|
|
178
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
|
+
|
|
179
220
|
// Purchase metadata
|
|
180
221
|
if (metadata?.source) {
|
|
181
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
|
|
|
@@ -110,16 +111,18 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
|
|
|
110
111
|
isPremium: boolean,
|
|
111
112
|
productId?: string,
|
|
112
113
|
expiresAt?: string,
|
|
113
|
-
willRenew?: boolean
|
|
114
|
+
willRenew?: boolean,
|
|
115
|
+
periodType?: "NORMAL" | "INTRO" | "TRIAL"
|
|
114
116
|
) => {
|
|
115
117
|
if (__DEV__) {
|
|
116
|
-
console.log('[SubscriptionInitializer] onPremiumStatusChanged:', { userId, isPremium, productId, willRenew });
|
|
118
|
+
console.log('[SubscriptionInitializer] onPremiumStatusChanged:', { userId, isPremium, productId, willRenew, periodType });
|
|
117
119
|
}
|
|
118
120
|
try {
|
|
119
121
|
const revenueCatData: RevenueCatData = {
|
|
120
122
|
expirationDate: expiresAt ?? null,
|
|
121
123
|
willRenew: willRenew ?? false,
|
|
122
124
|
isPremium,
|
|
125
|
+
periodType,
|
|
123
126
|
};
|
|
124
127
|
await getCreditsRepository().initializeCredits(
|
|
125
128
|
userId,
|
|
@@ -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;
|
|
@@ -20,7 +20,9 @@ export interface RevenueCatConfig {
|
|
|
20
20
|
isPremium: boolean,
|
|
21
21
|
productId?: string,
|
|
22
22
|
expiresAt?: string,
|
|
23
|
-
willRenew?: boolean
|
|
23
|
+
willRenew?: boolean,
|
|
24
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
25
|
+
periodType?: "NORMAL" | "INTRO" | "TRIAL"
|
|
24
26
|
) => Promise<void> | void;
|
|
25
27
|
/** Callback for purchase completion */
|
|
26
28
|
onPurchaseCompleted?: (
|
|
@@ -28,10 +28,11 @@ export async function syncPremiumStatus(
|
|
|
28
28
|
true,
|
|
29
29
|
premiumEntitlement.productIdentifier,
|
|
30
30
|
premiumEntitlement.expirationDate ?? undefined,
|
|
31
|
-
premiumEntitlement.willRenew
|
|
31
|
+
premiumEntitlement.willRenew,
|
|
32
|
+
premiumEntitlement.periodType as "NORMAL" | "INTRO" | "TRIAL" | undefined
|
|
32
33
|
);
|
|
33
34
|
} else {
|
|
34
|
-
await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined);
|
|
35
|
+
await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined, undefined);
|
|
35
36
|
}
|
|
36
37
|
} catch {
|
|
37
38
|
// Silent error handling
|