@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.24.17",
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: SubscriptionStatus;
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: 'none',
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 { createDefaultSubscriptionStatus, isSubscriptionValid } from "./domain/entities/SubscriptionStatus";
11
- export type { SubscriptionStatus, SubscriptionStatusType } from "./domain/entities/SubscriptionStatus";
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, SubscriptionStatus } from "../../domain/entities/Credits";
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
- ): { isPremium: boolean; status: SubscriptionStatus } {
45
- const docIsPremium = doc.isPremium ?? false;
46
-
47
- // No expiration date = lifetime or free
48
- if (!expirationDate) {
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
- if (isExpired) {
59
- // Subscription expired - override document's isPremium
60
- return { isPremium: false, status: "expired" };
61
- }
60
+ const status = resolveSubscriptionStatus({
61
+ isPremium,
62
+ willRenew,
63
+ isExpired,
64
+ periodType,
65
+ });
62
66
 
63
- // Subscription still active
67
+ // Override isPremium if expired
64
68
  return {
65
- isPremium: docIsPremium,
66
- status: docIsPremium ? "active" : "free",
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 based on isPremium and willRenew
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
- // Status logic: canceled if premium but willRenew=false, expired if not premium, active otherwise
130
- let status: SubscriptionDocStatus;
131
- if (!isPremium) {
132
- status = "expired";
133
- } else if (willRenew === false) {
134
- status = "canceled";
135
- } else {
136
- status = "active";
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
- // Status type: prioritize Firestore status, then derive from willRenew + expiration
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 renewal status
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
- ): "active" | "canceled" | "expired" | "none" {
50
- if (!isPremium) {
51
- return "none";
52
- }
53
-
54
- // Lifetime subscription (no expiration) - always active
55
- if (!expiresAt) {
56
- return "active";
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