@umituz/react-native-subscription 2.24.17 → 2.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/domain/entities/Credits.ts +10 -3
- package/src/domain/entities/SubscriptionStatus.ts +45 -1
- package/src/index.ts +18 -2
- package/src/infrastructure/mappers/CreditsMapper.ts +28 -24
- package/src/infrastructure/models/UserCreditsDocument.ts +12 -1
- package/src/infrastructure/repositories/CreditsRepository.ts +3 -0
- package/src/infrastructure/services/CreditsInitializer.ts +41 -10
- 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 +13 -28
- 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.1",
|
|
4
4
|
"description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { SubscriptionPackageType } from "../../utils/packageTypeDetector";
|
|
9
|
+
import type { SubscriptionStatusType, PeriodType } from "./SubscriptionStatus";
|
|
9
10
|
|
|
10
11
|
export type CreditType = "text" | "image";
|
|
11
12
|
|
|
@@ -19,13 +20,11 @@ export type PurchaseSource =
|
|
|
19
20
|
|
|
20
21
|
export type PurchaseType = "initial" | "renewal" | "upgrade" | "downgrade";
|
|
21
22
|
|
|
22
|
-
export type SubscriptionStatus = "active" | "expired" | "canceled" | "free";
|
|
23
|
-
|
|
24
23
|
/** Single Source of Truth for user subscription + credits data */
|
|
25
24
|
export interface UserCredits {
|
|
26
25
|
// Core subscription
|
|
27
26
|
isPremium: boolean;
|
|
28
|
-
status:
|
|
27
|
+
status: SubscriptionStatusType;
|
|
29
28
|
|
|
30
29
|
// Dates
|
|
31
30
|
purchasedAt: Date | null;
|
|
@@ -38,6 +37,14 @@ export interface UserCredits {
|
|
|
38
37
|
packageType?: "weekly" | "monthly" | "yearly" | "lifetime";
|
|
39
38
|
originalTransactionId?: string;
|
|
40
39
|
|
|
40
|
+
// Trial fields
|
|
41
|
+
periodType?: PeriodType;
|
|
42
|
+
isTrialing?: boolean;
|
|
43
|
+
trialStartDate?: Date | null;
|
|
44
|
+
trialEndDate?: Date | null;
|
|
45
|
+
trialCredits?: number;
|
|
46
|
+
convertedFromTrial?: boolean;
|
|
47
|
+
|
|
41
48
|
// Credits
|
|
42
49
|
credits: number;
|
|
43
50
|
creditLimit?: number;
|
|
@@ -2,11 +2,22 @@ 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 type constants */
|
|
13
|
+
export const PERIOD_TYPE = {
|
|
14
|
+
NORMAL: 'NORMAL',
|
|
15
|
+
INTRO: 'INTRO',
|
|
16
|
+
TRIAL: 'TRIAL',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type PeriodType = (typeof PERIOD_TYPE)[keyof typeof PERIOD_TYPE];
|
|
20
|
+
|
|
10
21
|
export type SubscriptionStatusType = (typeof SUBSCRIPTION_STATUS)[keyof typeof SUBSCRIPTION_STATUS];
|
|
11
22
|
|
|
12
23
|
export interface SubscriptionStatus {
|
|
@@ -17,6 +28,10 @@ export interface SubscriptionStatus {
|
|
|
17
28
|
customerId?: string | null;
|
|
18
29
|
syncedAt?: string | null;
|
|
19
30
|
status?: SubscriptionStatusType;
|
|
31
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
32
|
+
periodType?: PeriodType;
|
|
33
|
+
/** Whether user is currently in trial period */
|
|
34
|
+
isTrialing?: boolean;
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
export const createDefaultSubscriptionStatus = (): SubscriptionStatus => ({
|
|
@@ -26,7 +41,7 @@ export const createDefaultSubscriptionStatus = (): SubscriptionStatus => ({
|
|
|
26
41
|
purchasedAt: null,
|
|
27
42
|
customerId: null,
|
|
28
43
|
syncedAt: null,
|
|
29
|
-
status:
|
|
44
|
+
status: SUBSCRIPTION_STATUS.NONE,
|
|
30
45
|
});
|
|
31
46
|
|
|
32
47
|
export const isSubscriptionValid = (status: SubscriptionStatus | null): boolean => {
|
|
@@ -41,3 +56,32 @@ export const calculateDaysRemaining = (expiresAt: string | null): number | null
|
|
|
41
56
|
return timezoneService.getDaysUntil(new Date(expiresAt));
|
|
42
57
|
};
|
|
43
58
|
|
|
59
|
+
/** Subscription status resolver input */
|
|
60
|
+
export interface StatusResolverInput {
|
|
61
|
+
isPremium: boolean;
|
|
62
|
+
willRenew?: boolean;
|
|
63
|
+
isExpired?: boolean;
|
|
64
|
+
periodType?: PeriodType;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolves subscription status from input parameters
|
|
69
|
+
* Single source of truth for status determination logic
|
|
70
|
+
*/
|
|
71
|
+
export const resolveSubscriptionStatus = (input: StatusResolverInput): SubscriptionStatusType => {
|
|
72
|
+
const { isPremium, willRenew, isExpired, periodType } = input;
|
|
73
|
+
|
|
74
|
+
if (!isPremium || isExpired) {
|
|
75
|
+
return isExpired ? SUBSCRIPTION_STATUS.EXPIRED : SUBSCRIPTION_STATUS.NONE;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isTrial = periodType === PERIOD_TYPE.TRIAL;
|
|
79
|
+
const isCanceled = willRenew === false;
|
|
80
|
+
|
|
81
|
+
if (isTrial) {
|
|
82
|
+
return isCanceled ? SUBSCRIPTION_STATUS.TRIAL_CANCELED : SUBSCRIPTION_STATUS.TRIAL;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return isCanceled ? SUBSCRIPTION_STATUS.CANCELED : SUBSCRIPTION_STATUS.ACTIVE;
|
|
86
|
+
};
|
|
87
|
+
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,14 @@ export * from "./domains/paywall";
|
|
|
7
7
|
export * from "./domains/config";
|
|
8
8
|
|
|
9
9
|
// Domain Layer
|
|
10
|
-
export {
|
|
11
|
-
|
|
10
|
+
export {
|
|
11
|
+
SUBSCRIPTION_STATUS,
|
|
12
|
+
PERIOD_TYPE,
|
|
13
|
+
createDefaultSubscriptionStatus,
|
|
14
|
+
isSubscriptionValid,
|
|
15
|
+
resolveSubscriptionStatus,
|
|
16
|
+
} from "./domain/entities/SubscriptionStatus";
|
|
17
|
+
export type { SubscriptionStatus, SubscriptionStatusType, PeriodType, StatusResolverInput } from "./domain/entities/SubscriptionStatus";
|
|
12
18
|
export type { SubscriptionConfig } from "./domain/value-objects/SubscriptionConfig";
|
|
13
19
|
export type { ISubscriptionRepository } from "./application/ports/ISubscriptionRepository";
|
|
14
20
|
|
|
@@ -22,6 +28,16 @@ export {
|
|
|
22
28
|
type FeedbackSubmitResult,
|
|
23
29
|
} from "./infrastructure/services/FeedbackService";
|
|
24
30
|
export { initializeSubscription, type SubscriptionInitConfig, type CreditPackageConfig } from "./infrastructure/services/SubscriptionInitializer";
|
|
31
|
+
export {
|
|
32
|
+
getDeviceId,
|
|
33
|
+
checkTrialEligibility,
|
|
34
|
+
recordTrialStart,
|
|
35
|
+
recordTrialEnd,
|
|
36
|
+
recordTrialConversion,
|
|
37
|
+
TRIAL_CONFIG,
|
|
38
|
+
type DeviceTrialRecord,
|
|
39
|
+
type TrialEligibilityResult,
|
|
40
|
+
} from "./infrastructure/services/TrialService";
|
|
25
41
|
export { CreditsRepository, createCreditsRepository } from "./infrastructure/repositories/CreditsRepository";
|
|
26
42
|
export { configureCreditsRepository, getCreditsRepository, getCreditsConfig, resetCreditsRepository, isCreditsRepositoryConfigured } from "./infrastructure/repositories/CreditsRepositoryProvider";
|
|
27
43
|
export {
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type { UserCredits
|
|
1
|
+
import type { UserCredits } from "../../domain/entities/Credits";
|
|
2
|
+
import { resolveSubscriptionStatus, type PeriodType, type SubscriptionStatusType } from "../../domain/entities/SubscriptionStatus";
|
|
2
3
|
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
3
4
|
|
|
4
5
|
/** Maps Firestore document to domain entity with expiration validation */
|
|
5
6
|
export class CreditsMapper {
|
|
6
7
|
static toEntity(doc: UserCreditsDocumentRead): UserCredits {
|
|
7
8
|
const expirationDate = doc.expirationDate?.toDate?.() ?? null;
|
|
9
|
+
const periodType = doc.periodType as PeriodType | undefined;
|
|
8
10
|
|
|
9
11
|
// Validate isPremium against expirationDate (real-time check)
|
|
10
|
-
const { isPremium, status } = CreditsMapper.validateSubscription(doc, expirationDate);
|
|
12
|
+
const { isPremium, status } = CreditsMapper.validateSubscription(doc, expirationDate, periodType);
|
|
11
13
|
|
|
12
14
|
return {
|
|
13
15
|
// Core subscription (validated)
|
|
@@ -25,6 +27,14 @@ export class CreditsMapper {
|
|
|
25
27
|
packageType: doc.packageType,
|
|
26
28
|
originalTransactionId: doc.originalTransactionId,
|
|
27
29
|
|
|
30
|
+
// Trial fields
|
|
31
|
+
periodType,
|
|
32
|
+
isTrialing: doc.isTrialing,
|
|
33
|
+
trialStartDate: doc.trialStartDate?.toDate?.() ?? null,
|
|
34
|
+
trialEndDate: doc.trialEndDate?.toDate?.() ?? null,
|
|
35
|
+
trialCredits: doc.trialCredits,
|
|
36
|
+
convertedFromTrial: doc.convertedFromTrial,
|
|
37
|
+
|
|
28
38
|
// Credits
|
|
29
39
|
credits: doc.credits,
|
|
30
40
|
creditLimit: doc.creditLimit,
|
|
@@ -37,33 +47,27 @@ export class CreditsMapper {
|
|
|
37
47
|
};
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
/** Validate subscription status against expirationDate */
|
|
50
|
+
/** Validate subscription status against expirationDate and periodType */
|
|
41
51
|
private static validateSubscription(
|
|
42
52
|
doc: UserCreditsDocumentRead,
|
|
43
|
-
expirationDate: Date | null
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
isPremium: docIsPremium,
|
|
51
|
-
status: docIsPremium ? "active" : "free",
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Check if subscription has expired
|
|
56
|
-
const isExpired = expirationDate < new Date();
|
|
53
|
+
expirationDate: Date | null,
|
|
54
|
+
periodType?: PeriodType
|
|
55
|
+
): { isPremium: boolean; status: SubscriptionStatusType } {
|
|
56
|
+
const isPremium = doc.isPremium ?? false;
|
|
57
|
+
const willRenew = doc.willRenew ?? false;
|
|
58
|
+
const isExpired = expirationDate ? expirationDate < new Date() : false;
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
const status = resolveSubscriptionStatus({
|
|
61
|
+
isPremium,
|
|
62
|
+
willRenew,
|
|
63
|
+
isExpired,
|
|
64
|
+
periodType,
|
|
65
|
+
});
|
|
62
66
|
|
|
63
|
-
//
|
|
67
|
+
// Override isPremium if expired
|
|
64
68
|
return {
|
|
65
|
-
isPremium:
|
|
66
|
-
status
|
|
69
|
+
isPremium: isExpired ? false : isPremium,
|
|
70
|
+
status,
|
|
67
71
|
};
|
|
68
72
|
}
|
|
69
73
|
}
|
|
@@ -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(
|
|
@@ -15,8 +15,9 @@ import type {
|
|
|
15
15
|
PurchaseSource,
|
|
16
16
|
PurchaseType,
|
|
17
17
|
PurchaseMetadata,
|
|
18
|
-
SubscriptionDocStatus,
|
|
19
18
|
} from "../models/UserCreditsDocument";
|
|
19
|
+
import { SUBSCRIPTION_STATUS, resolveSubscriptionStatus, type PeriodType } from "../../domain/entities/SubscriptionStatus";
|
|
20
|
+
import { TRIAL_CONFIG } from "./TrialService";
|
|
20
21
|
import { detectPackageType } from "../../utils/packageTypeDetector";
|
|
21
22
|
import { getCreditAllocation } from "../../utils/creditMapper";
|
|
22
23
|
|
|
@@ -34,6 +35,8 @@ export interface InitializeCreditsMetadata {
|
|
|
34
35
|
willRenew?: boolean;
|
|
35
36
|
originalTransactionId?: string;
|
|
36
37
|
isPremium?: boolean;
|
|
38
|
+
/** RevenueCat period type: NORMAL, INTRO, or TRIAL */
|
|
39
|
+
periodType?: PeriodType;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export async function initializeCreditsTransaction(
|
|
@@ -122,18 +125,24 @@ export async function initializeCreditsTransaction(
|
|
|
122
125
|
? [...(existing?.purchaseHistory || []), purchaseMetadata].slice(-10)
|
|
123
126
|
: existing?.purchaseHistory;
|
|
124
127
|
|
|
125
|
-
// Determine subscription status
|
|
128
|
+
// Determine subscription status
|
|
126
129
|
const isPremium = metadata?.isPremium ?? true;
|
|
127
130
|
const willRenew = metadata?.willRenew;
|
|
131
|
+
const periodType = metadata?.periodType;
|
|
128
132
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
const status = resolveSubscriptionStatus({
|
|
134
|
+
isPremium,
|
|
135
|
+
willRenew,
|
|
136
|
+
isExpired: !isPremium,
|
|
137
|
+
periodType,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Determine credits based on status
|
|
141
|
+
// Trial: 5 credits, Trial canceled: 0 credits, Normal: plan-based credits
|
|
142
|
+
if (status === SUBSCRIPTION_STATUS.TRIAL) {
|
|
143
|
+
newCredits = TRIAL_CONFIG.CREDITS;
|
|
144
|
+
} else if (status === SUBSCRIPTION_STATUS.TRIAL_CANCELED) {
|
|
145
|
+
newCredits = 0;
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
// Build credits data (Single Source of Truth)
|
|
@@ -176,6 +185,28 @@ export async function initializeCreditsTransaction(
|
|
|
176
185
|
creditsData.appVersion = appVersion;
|
|
177
186
|
}
|
|
178
187
|
|
|
188
|
+
// Trial-specific fields
|
|
189
|
+
const isTrialing = status === SUBSCRIPTION_STATUS.TRIAL || status === SUBSCRIPTION_STATUS.TRIAL_CANCELED;
|
|
190
|
+
|
|
191
|
+
if (periodType) {
|
|
192
|
+
creditsData.periodType = periodType;
|
|
193
|
+
}
|
|
194
|
+
if (isTrialing) {
|
|
195
|
+
creditsData.isTrialing = status === SUBSCRIPTION_STATUS.TRIAL;
|
|
196
|
+
creditsData.trialCredits = TRIAL_CONFIG.CREDITS;
|
|
197
|
+
// Set trial dates if this is a new trial
|
|
198
|
+
if (!existing?.trialStartDate) {
|
|
199
|
+
creditsData.trialStartDate = now;
|
|
200
|
+
}
|
|
201
|
+
if (metadata?.expirationDate) {
|
|
202
|
+
creditsData.trialEndDate = Timestamp.fromDate(new Date(metadata.expirationDate));
|
|
203
|
+
}
|
|
204
|
+
} else if (existing?.isTrialing && isPremium) {
|
|
205
|
+
// User converted from trial to paid
|
|
206
|
+
creditsData.isTrialing = false;
|
|
207
|
+
creditsData.convertedFromTrial = true;
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
// Purchase metadata
|
|
180
211
|
if (metadata?.source) {
|
|
181
212
|
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
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { useMemo } from "react";
|
|
7
7
|
import type { UserCredits } from "../../domain/entities/Credits";
|
|
8
|
+
import { resolveSubscriptionStatus, type PeriodType, type SubscriptionStatusType } from "../../domain/entities/SubscriptionStatus";
|
|
8
9
|
import type { SubscriptionSettingsTranslations } from "../types/SubscriptionSettingsTypes";
|
|
9
10
|
|
|
10
11
|
export interface CreditsInfo {
|
|
@@ -37,36 +38,20 @@ export function useCreditsArray(
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* Calculates subscription status type based on premium and
|
|
41
|
-
* @param isPremium - Whether user has premium subscription
|
|
42
|
-
* @param willRenew - Whether subscription will auto-renew (false = canceled)
|
|
43
|
-
* @param expiresAt - Expiration date ISO string (null for lifetime)
|
|
41
|
+
* Calculates subscription status type based on premium, renewal status, and period type
|
|
44
42
|
*/
|
|
45
43
|
export function getSubscriptionStatusType(
|
|
46
44
|
isPremium: boolean,
|
|
47
45
|
willRenew?: boolean,
|
|
48
|
-
expiresAt?: string | null
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// Check if expired
|
|
60
|
-
const now = new Date();
|
|
61
|
-
const expDate = new Date(expiresAt);
|
|
62
|
-
if (expDate < now) {
|
|
63
|
-
return "expired";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Premium with willRenew=false means subscription is canceled but still active until expiration
|
|
67
|
-
if (willRenew === false) {
|
|
68
|
-
return "canceled";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return "active";
|
|
46
|
+
expiresAt?: string | null,
|
|
47
|
+
periodType?: PeriodType
|
|
48
|
+
): SubscriptionStatusType {
|
|
49
|
+
const isExpired = expiresAt ? new Date(expiresAt) < new Date() : false;
|
|
50
|
+
|
|
51
|
+
return resolveSubscriptionStatus({
|
|
52
|
+
isPremium,
|
|
53
|
+
willRenew,
|
|
54
|
+
isExpired,
|
|
55
|
+
periodType,
|
|
56
|
+
});
|
|
72
57
|
}
|
|
@@ -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
|