@umituz/react-native-subscription 2.24.16 → 2.25.0

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