diff --git a/.env.example b/.env.example
index c006d5c..2ca31e6 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,10 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
EXPO_PUBLIC_ACCOUNT_DELETION_URL=
+EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
+EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
# Server-side only. Set these in Supabase Edge Function secrets, never in Expo public env.
SUPABASE_SERVICE_ROLE_KEY=
CLERK_SECRET_KEY=
+REVENUECAT_SECRET_API_KEY=
diff --git a/.env.preview.example b/.env.preview.example
index f755f4b..f46e867 100644
--- a/.env.preview.example
+++ b/.env.preview.example
@@ -4,6 +4,10 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
EXPO_PUBLIC_ACCOUNT_DELETION_URL=
+EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
+EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
# Keep server-only credentials in Supabase Edge Function secrets.
# Do not add them to this file or an EAS mobile build environment.
+# Required by AI Edge Functions for server-side subscription validation:
+# REVENUECAT_SECRET_API_KEY
diff --git a/.env.production.example b/.env.production.example
index a43c147..d2d5be0 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -4,6 +4,10 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
EXPO_PUBLIC_ACCOUNT_DELETION_URL=
+EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
+EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
# Production server credentials are intentionally pending.
# Keep them in backend secret storage, never in the mobile build environment.
+# Required by AI Edge Functions for server-side subscription validation:
+# REVENUECAT_SECRET_API_KEY
diff --git a/.gitignore b/.gitignore
index 32d1d2c..8eb225a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,3 +95,4 @@ PROMPTS.md
task.md
DB.md
AI.md
+DearDiary.md
diff --git a/.maestro/00-launch.yaml b/.maestro/00-launch.yaml
new file mode 100644
index 0000000..9473a93
--- /dev/null
+++ b/.maestro/00-launch.yaml
@@ -0,0 +1,27 @@
+appId: com.aryan.deardiary
+---
+- launchApp:
+ clearState: false
+
+- assertVisible:
+ text: "DearDiary"
+ optional: true
+
+- assertVisible:
+ text: "Today"
+
+- assertVisible:
+ text: "Reflect"
+ optional: true
+
+- assertVisible:
+ text: "History"
+ optional: true
+
+- assertVisible:
+ text: "Insights"
+ optional: true
+
+- assertVisible:
+ text: "Profile"
+ optional: true
diff --git a/.maestro/00-smoke-launch.yaml b/.maestro/00-smoke-launch.yaml
new file mode 100644
index 0000000..1b33a6e
--- /dev/null
+++ b/.maestro/00-smoke-launch.yaml
@@ -0,0 +1,35 @@
+appId: com.aryan.deardiary
+---
+- launchApp:
+ clearState: false
+
+- assertVisible:
+ id: "login-screen"
+ optional: true
+
+- assertVisible:
+ id: "signup-screen"
+ optional: true
+
+- assertVisible:
+ id: "home-screen"
+ optional: true
+
+- assertVisible:
+ id: "app-lock-screen"
+ optional: true
+
+- tapOn:
+ id: "tab-profile-button"
+ optional: true
+
+- assertVisible:
+ id: "profile-screen"
+ optional: true
+
+- tapOn:
+ id: "tab-today-button"
+ optional: true
+
+- assertVisible:
+ id: "home-screen"
diff --git a/app.json b/app.json
index d2ac7b5..0fccd7c 100644
--- a/app.json
+++ b/app.json
@@ -2,14 +2,14 @@
"expo": {
"name": "DearDiary",
"slug": "dear-diary",
- "version": "1.0.4",
+ "version": "1.0.6",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "deardiary",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
- "buildNumber": "4",
+ "buildNumber": "6",
"supportsTablet": true
},
"android": {
@@ -21,7 +21,8 @@
],
"icon": "./assets/images/icon.png",
"package": "com.aryan.deardiary",
- "versionCode": 4,
+ "permissions": ["com.android.vending.BILLING"],
+ "versionCode": 6,
"adaptiveIcon": {
"backgroundColor": "#FFDDE8",
"foregroundImage": "./assets/images/icon.png"
diff --git a/app/(onboarding)/onboarding-screen-1.tsx b/app/(onboarding)/onboarding-screen-1.tsx
index dbc60bf..721d454 100644
--- a/app/(onboarding)/onboarding-screen-1.tsx
+++ b/app/(onboarding)/onboarding-screen-1.tsx
@@ -308,6 +308,9 @@ export default function OnboardingScreenOne() {
>
diff --git a/app/(onboarding)/onboarding-screen-2.tsx b/app/(onboarding)/onboarding-screen-2.tsx
index c7f4b3e..d5c4662 100644
--- a/app/(onboarding)/onboarding-screen-2.tsx
+++ b/app/(onboarding)/onboarding-screen-2.tsx
@@ -372,6 +372,9 @@ export default function OnboardingScreenTwo() {
>
-
+
{
+ if (isLoaded && !isSignedIn) {
+ router.replace("/login");
+ }
+ }, [isLoaded, isSignedIn, router]);
+
if (!isLoaded) {
return null;
}
if (!isSignedIn) {
- return ;
+ return null;
}
return (
diff --git a/app/_layout.tsx b/app/_layout.tsx
index d3bbda8..4bc22e0 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -18,6 +18,7 @@ import { useNativeTransitionOptions } from "@/navigation/transitions";
import { AppLockProvider } from "@/providers/AppLockProvider";
import { AppDialogProvider } from "@/providers/AppDialogProvider";
import { ConnectivityProvider } from "@/providers/ConnectivityProvider";
+import { SubscriptionProvider } from "@/providers/SubscriptionProvider";
export default function RootLayout() {
if (!publicEnvironmentResult.isValid) {
@@ -40,7 +41,9 @@ export default function RootLayout() {
>
-
+
+
+
@@ -111,6 +114,7 @@ function RootNavigator() {
name="legal/privacy-policy"
options={standardDetailOptions}
/>
+
diff --git a/app/insights/report/[periodType].tsx b/app/insights/report/[periodType].tsx
index 690eaf7..91f3b9d 100644
--- a/app/insights/report/[periodType].tsx
+++ b/app/insights/report/[periodType].tsx
@@ -1,5 +1,11 @@
import { useAuth } from "@clerk/expo";
-import { Redirect, Stack, useLocalSearchParams, useRouter } from "expo-router";
+import {
+ Redirect,
+ Stack,
+ type Href,
+ useLocalSearchParams,
+ useRouter,
+} from "expo-router";
import { ChevronLeft, RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import {
@@ -39,18 +45,18 @@ import {
import { FeatureErrorBoundary } from "@/components/errors/FeatureErrorBoundary";
import { ReportStatGrid } from "@/components/insights/report/ReportStatGrid";
import { reportColors } from "@/constants/report-theme";
-import { useAppDialog } from "@/hooks/useAppDialog";
import { useAIInsightReport } from "@/hooks/useAIInsightReport";
+import { useFeatureAccess } from "@/hooks/useFeatureAccess";
+import { useReportAccessActions } from "@/hooks/useReportAccessActions";
import {
getCurrentReportPeriod,
isAIInsightPeriodType,
} from "@/lib/insights/reportPeriods";
export default function AIInsightReportScreen() {
- const { isLoaded, isSignedIn } = useAuth();
+ const { isLoaded, isSignedIn, userId } = useAuth();
const insets = useSafeAreaInsets();
const router = useRouter();
- const { showDialog } = useAppDialog();
const params = useLocalSearchParams<{ periodType?: string }>();
const periodTypeParam = Array.isArray(params.periodType)
? params.periodType[0]
@@ -64,29 +70,23 @@ export default function AIInsightReportScreen() {
const reportState = useAIInsightReport(period, {
enabled: isValidPeriodType,
});
+ const reportFeature =
+ period.type === "weekly" ? "weekly_report" : "monthly_report";
+ const reportAccess = useFeatureAccess(reportFeature, userId);
+ const {
+ fairUseLimitMessage,
+ handleGenerate,
+ handleRegenerate,
+ } = useReportAccessActions({
+ feature: reportFeature,
+ reportAccess,
+ reportState,
+ userId,
+ });
const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom;
const minimumEntries = period.type === "weekly" ? 2 : 3;
const hasEnoughEntries = reportState.availableEntryCount >= minimumEntries;
- function handleGenerate() {
- void reportState.generate();
- }
-
- function handleRegenerate() {
- showDialog({
- cancelText: "Keep Current",
- confirmText: "Regenerate",
- icon: "✦",
- message:
- "DearDiary will analyze this period again and replace the current visual reflection.",
- onConfirm: () => {
- void reportState.regenerate();
- },
- showCancel: true,
- title: "Regenerate reflection?",
- });
- }
-
if (!isLoaded) {
return null;
}
@@ -117,6 +117,41 @@ export default function AIInsightReportScreen() {
);
}
+ if (
+ period.type === "monthly" &&
+ !reportAccess.allowed &&
+ reportAccess.reason !== "Pro_fair_use_exhausted"
+ ) {
+ return (
+
+
+
+ Monthly AI reports are included with DearDiary Pro.
+
+
+ Unlock monthly summaries, advanced patterns, and long-term writing
+ insights.
+
+
+ router.push({
+ pathname: "/paywall",
+ params: { feature: "monthly_report" },
+ } as unknown as Href)
+ }
+ />
+
+
+ );
+ }
+
return (
@@ -126,6 +161,7 @@ export default function AIInsightReportScreen() {
onRetry={reportState.refresh}
>
router.back()}
>
@@ -150,6 +187,7 @@ export default function AIInsightReportScreen() {
Reflection Report
+ {fairUseLimitMessage ? (
+
+ ) : null}
+
{reportState.error ? (
();
+ const feature = getSingleRouteParam(params.feature);
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/settings/app-lock/setup.tsx b/app/settings/app-lock/setup.tsx
index e990f4e..cde6560 100644
--- a/app/settings/app-lock/setup.tsx
+++ b/app/settings/app-lock/setup.tsx
@@ -5,12 +5,12 @@ import {
ActivityIndicator,
Pressable,
ScrollView,
- Switch,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { BiometricLockSwitch } from "@/components/app-lock/BiometricLockSwitch";
import { PinInput } from "@/components/app-lock/PinInput";
import { AnimatedIconButton } from "@/components/ui/animated-icon-button";
import { appLockColors, appLockLayout } from "@/constants/app-lock-theme";
@@ -108,6 +108,7 @@ export default function AppLockSetupScreen() {
style={{ backgroundColor: appLockColors.background }}
>
router.back()}
shadow="0 2px 6px rgba(39, 39, 42, 0.16)"
@@ -153,7 +155,9 @@ export default function AppLockSetupScreen() {
Create a six-digit PIN
{helperMessage ? (
@@ -218,7 +225,8 @@ export default function AppLockSetupScreen() {
: "Biometrics are not available or enrolled on this device."}
-
{delayOptions.map((option) => (
router.push(privacyPolicyHref)}
+ testID="profile-privacy-policy-link"
value=""
/>
@@ -107,6 +110,7 @@ export default function PrivacySettingsScreen() {
icon="clipboard"
label="Terms & Conditions"
onPress={() => router.push(termsHref)}
+ testID="profile-terms-link"
value=""
/>
{accountDeletionUrl ? (
@@ -116,6 +120,7 @@ export default function PrivacySettingsScreen() {
icon="external-link"
label="External Deletion Page"
onPress={() => router.push(accountDeletionUrl as Href)}
+ testID="settings-external-deletion-row"
value="View"
/>
>
@@ -130,6 +135,7 @@ export default function PrivacySettingsScreen() {
isBusy={busyAction === "disable-lock"}
label="App Lock"
onPress={handleAppLockPress}
+ testID="settings-app-lock-row"
value={isEnabled ? "On" : "Off"}
/>
diff --git a/app/sso.tsx b/app/sso.tsx
index eeece81..82ae6d1 100644
--- a/app/sso.tsx
+++ b/app/sso.tsx
@@ -12,7 +12,10 @@ export default function SsoCallbackScreen() {
>
-
+
Finishing sign in...
diff --git a/assets/images/landing-page.png b/assets/images/landing-page.png
new file mode 100644
index 0000000..0c7bc32
Binary files /dev/null and b/assets/images/landing-page.png differ
diff --git a/components/ai-chat/ai-chat-screen.tsx b/components/ai-chat/ai-chat-screen.tsx
index 812dae8..00606f8 100644
--- a/components/ai-chat/ai-chat-screen.tsx
+++ b/components/ai-chat/ai-chat-screen.tsx
@@ -1,7 +1,7 @@
import { Feather } from "@expo/vector-icons";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
-import { router } from "expo-router";
+import { router, type Href } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { Sparkles } from "lucide-react-native";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -29,11 +29,14 @@ import { CONNECTION_STATE_COLORS } from "@/constants/theme";
import { useAppDialog } from "@/hooks/useAppDialog";
import { useConnectivity } from "@/hooks/useConnectivity";
import { useDelayedVisibility } from "@/hooks/useDelayedVisibility";
+import { useFeatureAccess } from "@/hooks/useFeatureAccess";
+import { detectChatIntent } from "@/lib/ai/chatIntent";
import {
generateRemoteJournalResponse,
RemoteJournalAssistantError,
} from "@/lib/ai/remoteJournalAssistant";
import { addSafeBreakOpportunities } from "@/lib/text/add-safe-break-opportunities";
+import { useAIUsageStore } from "@/store/useAIUsageStore";
import { useChatStore } from "@/store/useChatStore";
import type { ChatMessage } from "@/types/chat";
@@ -79,6 +82,7 @@ export function AiChatScreen({
const [isEmojiPickerVisible, setIsEmojiPickerVisible] = useState(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
+ const aiChatAccess = useFeatureAccess("ai_chat", userId);
const chatMessages = useChatStore((state) => state.messages);
const chatHasHydrated = useChatStore((state) => state.hasHydrated);
const chatHydrationError = useChatStore((state) => state.hydrationError);
@@ -271,6 +275,28 @@ export function AiChatScreen({
return;
}
+ const resolvedDateTimeOptions = Intl.DateTimeFormat().resolvedOptions();
+ const clientContext = {
+ currentDateTimeISO: new Date().toISOString(),
+ locale: resolvedDateTimeOptions.locale || "en-IN",
+ timezone: resolvedDateTimeOptions.timeZone,
+ };
+ const recentMessages = currentUserMessages.slice(-10).map((chatMessage) => ({
+ content: chatMessage.content,
+ relatedEntryIds: chatMessage.relatedEntryIds,
+ role: chatMessage.role,
+ }));
+ const intent = detectChatIntent(trimmedMessage, recentMessages);
+ const countsTowardAIQuota = shouldCountChatIntentTowardQuota(intent);
+
+ if (countsTowardAIQuota && !aiChatAccess.allowed) {
+ handleDeniedAIAccess({
+ feature: "ai_chat",
+ reason: aiChatAccess.reason,
+ });
+ return;
+ }
+
const userMessage: ChatMessage = {
content: trimmedMessage,
createdAt: new Date().toISOString(),
@@ -286,17 +312,6 @@ export function AiChatScreen({
setIsThinking(true);
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
- const resolvedDateTimeOptions = Intl.DateTimeFormat().resolvedOptions();
- const clientContext = {
- currentDateTimeISO: new Date().toISOString(),
- locale: resolvedDateTimeOptions.locale || "en-IN",
- timezone: resolvedDateTimeOptions.timeZone,
- };
- const recentMessages = currentUserMessages.slice(-10).map((chatMessage) => ({
- content: chatMessage.content,
- relatedEntryIds: chatMessage.relatedEntryIds,
- role: chatMessage.role,
- }));
try {
const remoteResponse = await generateRemoteJournalResponse({
@@ -323,6 +338,10 @@ export function AiChatScreen({
source: remoteResponse.source,
userId,
});
+
+ if (countsTowardAIQuota) {
+ useAIUsageStore.getState().incrementMonthlyUsage(userId, "ai_chat");
+ }
} catch (error) {
if (requestIdRef.current !== requestId) {
return;
@@ -339,6 +358,43 @@ export function AiChatScreen({
);
}
+ if (
+ error instanceof RemoteJournalAssistantError &&
+ error.code === "quota_exhausted"
+ ) {
+ addMessage({
+ content: error.message,
+ createdAt: new Date().toISOString(),
+ id: createChatMessageId(),
+ role: "assistant",
+ userId,
+ });
+ router.push({
+ pathname: "/paywall",
+ params: { feature: "ai_chat" },
+ } as unknown as Href);
+ return;
+ }
+
+ if (
+ error instanceof RemoteJournalAssistantError &&
+ error.code === "pro_fair_use_exhausted"
+ ) {
+ addMessage({
+ content: error.message,
+ createdAt: new Date().toISOString(),
+ id: createChatMessageId(),
+ role: "assistant",
+ userId,
+ });
+ showDialog({
+ confirmText: "OK",
+ message: error.message,
+ title: "Monthly AI Chat limit reached",
+ });
+ return;
+ }
+
addMessage({
content: "I couldn't respond just now. Please try again in a moment.",
createdAt: new Date().toISOString(),
@@ -361,6 +417,29 @@ export function AiChatScreen({
}
}
+ function handleDeniedAIAccess({
+ feature,
+ reason,
+ }: {
+ feature: "ai_chat";
+ reason: typeof aiChatAccess.reason;
+ }) {
+ if (reason === "Pro_fair_use_exhausted") {
+ showDialog({
+ confirmText: "OK",
+ message:
+ "You've reached this month's DearDiary Pro fair-use limit for AI Chat. Please try again next month.",
+ title: "Monthly AI Chat limit reached",
+ });
+ return;
+ }
+
+ router.push({
+ pathname: "/paywall",
+ params: { feature },
+ } as unknown as Href);
+ }
+
return (
0 ? (
) : null}
{!chatHasHydrated && showChatHydrationState ? (
-
+
Preparing your conversation...
) : null}
{chatHydrationError ? (
-
+
+
+
) : null}
{visibleMessages.map((chatMessage, index) => (
))}
{isThinking ? (
-
+
@@ -503,6 +591,8 @@ export function AiChatScreen({
{showJumpToLatest ? (
,
+) {
+ return ![
+ "app_capability",
+ "crisis",
+ "date_time",
+ "prompt_generation",
+ "small_talk",
+ "unsupported",
+ ].includes(intent);
+}
+
function ThinkingText() {
const opacity = useRef(new Animated.Value(0.42)).current;
const translateY = useRef(new Animated.Value(0)).current;
diff --git a/components/app-lock/AppLockScreen.tsx b/components/app-lock/AppLockScreen.tsx
index b6054f5..07d8171 100644
--- a/components/app-lock/AppLockScreen.tsx
+++ b/components/app-lock/AppLockScreen.tsx
@@ -1,4 +1,3 @@
-import { useClerk } from "@clerk/expo";
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
@@ -14,16 +13,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PinInput } from "@/components/app-lock/PinInput";
import { useAppLock } from "@/hooks/useAppLock";
-import {
- getSupabaseAccessTokenProvider,
- setSupabaseAccessTokenProvider,
-} from "@/lib/supabase";
-import { useJournalStore } from "@/store/journal-store";
+import { useAppSignOut } from "@/hooks/useAppSignOut";
export function AppLockScreen() {
const insets = useSafeAreaInsets();
- const { signOut } = useClerk();
- const setActiveUserId = useJournalStore((state) => state.setActiveUserId);
+ const signOutApp = useAppSignOut();
const {
biometricAvailability,
config,
@@ -153,18 +147,11 @@ export function AppLockScreen() {
return;
}
- const previousActiveUserId = useJournalStore.getState().activeUserId;
- const previousAccessTokenProvider = getSupabaseAccessTokenProvider();
-
setSigningOut(true);
try {
- await signOut();
- setActiveUserId(null);
- setSupabaseAccessTokenProvider(null);
+ await signOutApp();
} catch (error) {
- setActiveUserId(previousActiveUserId);
- setSupabaseAccessTokenProvider(previousAccessTokenProvider);
setMessage(
error instanceof Error
? error.message
@@ -181,6 +168,7 @@ export function AppLockScreen() {
className="flex-1 bg-[#FFF7FB]"
>
@@ -224,6 +215,8 @@ export function AppLockScreen() {
) : null}
void;
+ testID?: string;
value: string;
}) {
return (
void;
+ testID?: string;
+ value: boolean;
+};
+
+const switchColors = {
+ off: "#FB7185",
+ on: "#10B981",
+ thumb: "#F9FAFB",
+} as const;
+
+const switchSize = {
+ height: 48,
+ icon: 34,
+ iconOffset: 7,
+ thumb: 40,
+ thumbOffset: 4,
+ travel: 48,
+ width: 96,
+} as const;
+
+export function BiometricLockSwitch({
+ accessibilityLabel,
+ disabled = false,
+ onValueChange,
+ testID,
+ value,
+}: BiometricLockSwitchProps) {
+ const [progress] = useState(() => new Animated.Value(value ? 1 : 0));
+ const [thumbScale] = useState(() => new Animated.Value(1));
+
+ useEffect(() => {
+ Animated.timing(progress, {
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ toValue: value ? 1 : 0,
+ useNativeDriver: false,
+ }).start();
+ }, [progress, value]);
+
+ function handlePress() {
+ if (disabled) {
+ return;
+ }
+
+ onValueChange(!value);
+ }
+
+ function handlePressIn() {
+ if (disabled) {
+ return;
+ }
+
+ Animated.spring(thumbScale, {
+ friction: 8,
+ tension: 180,
+ toValue: 0.95,
+ useNativeDriver: false,
+ }).start();
+ }
+
+ function handlePressOut() {
+ Animated.spring(thumbScale, {
+ friction: 7,
+ tension: 150,
+ toValue: 1,
+ useNativeDriver: false,
+ }).start();
+ }
+
+ const backgroundColor = progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [switchColors.off, switchColors.on],
+ });
+ const thumbTranslate = progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, switchSize.travel],
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function LockIcon() {
+ return (
+
+ );
+}
+
+function UnlockIcon() {
+ return (
+
+ );
+}
diff --git a/components/app-lock/PinInput.tsx b/components/app-lock/PinInput.tsx
index 8e1a8b8..dc3701a 100644
--- a/components/app-lock/PinInput.tsx
+++ b/components/app-lock/PinInput.tsx
@@ -1,5 +1,6 @@
-import { useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import {
+ Keyboard,
Pressable,
Text,
TextInput,
@@ -7,25 +8,33 @@ import {
View,
} from "react-native";
+import { appLockColors } from "@/constants/app-lock-theme";
+
type PinInputProps = {
accessibilityLabel: string;
+ accessibilityHint?: string;
autoFocus?: boolean;
disabled?: boolean;
onChangePin: (pin: string) => void;
onSubmit?: () => void;
pin: string;
+ testID?: string;
};
export function PinInput({
+ accessibilityHint,
accessibilityLabel,
autoFocus = false,
disabled = false,
onChangePin,
onSubmit,
pin,
+ testID,
}: PinInputProps) {
const inputRef = useRef(null);
+ const [isFocused, setFocused] = useState(false);
const digits = Array.from({ length: 6 }, (_, index) => pin[index] ?? "");
+ const activeIndex = Math.min(pin.length, digits.length - 1);
function handleChangeText(value: string) {
onChangePin(value.replace(/\D/g, "").slice(0, 6));
@@ -34,8 +43,29 @@ export function PinInput({
const returnKeyType: TextInputProps["returnKeyType"] =
pin.length === 6 ? "done" : "default";
+ function clearFocus() {
+ inputRef.current?.blur();
+ setFocused(false);
+ }
+
+ useEffect(() => {
+ const subscription = Keyboard.addListener("keyboardDidHide", clearFocus);
+
+ return () => {
+ subscription.remove();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (disabled) {
+ clearFocus();
+ }
+ }, [disabled]);
+
return (
{digits.map((digit, index) => (
-
+
{digit ? "•" : ""}
@@ -68,6 +108,8 @@ export function PinInput({
keyboardType="number-pad"
maxLength={6}
onChangeText={handleChangeText}
+ onBlur={() => setFocused(false)}
+ onFocus={() => setFocused(true)}
onSubmitEditing={onSubmit}
ref={inputRef}
returnKeyType={returnKeyType}
diff --git a/components/auth/auth-screen.tsx b/components/auth/auth-screen.tsx
index 4751f81..ee8ce93 100644
--- a/components/auth/auth-screen.tsx
+++ b/components/auth/auth-screen.tsx
@@ -98,11 +98,13 @@ export function AuthScreen({
const resetOnboarding = useOnboardingStore((state) => state.resetOnboarding);
const isFetching =
signInFetchStatus === "fetching" || signUpFetchStatus === "fetching";
+ const authTestIdPrefix = isLogin ? "login" : "signup";
const isOffline = connectivity.status === "offline";
const isClerkReady = isAuthLoaded && !isFetching;
- const isAuthActionDisabled = !isClerkReady || isOffline || isSubmitting;
+ const hasActiveAuthRequest = isSubmitting || socialStrategy !== null;
+ const isAuthActionDisabled = !isClerkReady || isOffline || hasActiveAuthRequest;
const isSocialActionDisabled =
- !isClerkReady || isOffline || socialStrategy !== null;
+ !isClerkReady || isOffline || hasActiveAuthRequest;
const emailFeedback = getEmailFeedback(email);
const passwordFeedback = getPasswordFeedback(password, isLogin);
@@ -116,7 +118,7 @@ export function AuthScreen({
return;
}
- if (!isClerkReady || isSubmitting) {
+ if (!isClerkReady || hasActiveAuthRequest) {
return;
}
@@ -334,7 +336,7 @@ export function AuthScreen({
return;
}
- if (!isClerkReady || socialStrategy) {
+ if (!isClerkReady || hasActiveAuthRequest) {
return;
}
@@ -374,6 +376,7 @@ export function AuthScreen({
style={{ flex: 1 }}
>
) : null}
{isLogin && forgotPasswordHref ? (
-
+
{forgotPasswordText}
@@ -476,6 +498,8 @@ export function AuthScreen({
{isSubmitting ? (
-
+
) : null}
@@ -510,7 +538,12 @@ export function AuthScreen({
-
+
{isGoogleLoading ? (
-
+
) : (
{isAppleLoading ? (
-
+
) : (
)}
diff --git a/components/auth/auth-text-field.tsx b/components/auth/auth-text-field.tsx
index f83cf8e..4790419 100644
--- a/components/auth/auth-text-field.tsx
+++ b/components/auth/auth-text-field.tsx
@@ -2,6 +2,9 @@ import { Feather } from "@expo/vector-icons";
import { Pressable, Text, TextInput, View } from "react-native";
type AuthTextFieldProps = {
+ testID?: string;
+ accessibilityHint?: string;
+ accessibilityLabel?: string;
iconName: React.ComponentProps["name"];
label: string;
onChangeText: (text: string) => void;
@@ -13,10 +16,14 @@ type AuthTextFieldProps = {
onRightIconPress?: () => void;
rightAccessibilityLabel?: string;
rightIconName?: React.ComponentProps["name"];
+ rightTestID?: string;
secureTextEntry?: boolean;
};
export function AuthTextField({
+ testID,
+ accessibilityHint,
+ accessibilityLabel,
iconName,
label,
onChangeText,
@@ -28,6 +35,7 @@ export function AuthTextField({
onRightIconPress,
rightAccessibilityLabel,
rightIconName,
+ rightTestID,
secureTextEntry = false,
}: AuthTextFieldProps) {
return (
@@ -41,6 +49,9 @@ export function AuthTextField({
>
{rightIconName ? (
@@ -299,6 +301,7 @@ export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) {
{shouldShowReflectionCard ? (
-
+
{reflectionCardText}
-
+
Recent Entries
router.push(journalHistoryHref)}
@@ -446,7 +465,8 @@ export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) {
) : (
recentJournalEntries.map((entry) => (
{body}
{ctaLabel && onCtaPress ? (
@@ -165,7 +166,9 @@ export function HomeMoodCheckInCard() {
disabled={!hasHydrated || Boolean(moodLogHydrationError) || isSaving}
moods={moodList}
onSelectMood={handleSelectMood}
+ optionTestIDPrefix="home-mood"
selectedMoodId={selectedMoodId}
+ testID="home-mood-selector"
/>
{moodLogHydrationError ? (
@@ -174,6 +177,8 @@ export function HomeMoodCheckInCard() {
{helperText}
);
diff --git a/components/home/mood/MoodCheckInAction.tsx b/components/home/mood/MoodCheckInAction.tsx
index 57984f5..49cdb19 100644
--- a/components/home/mood/MoodCheckInAction.tsx
+++ b/components/home/mood/MoodCheckInAction.tsx
@@ -5,6 +5,7 @@ type MoodCheckInActionProps = {
isSaving: boolean;
label: string;
onPress: () => void;
+ testID?: string;
};
export function MoodCheckInAction({
@@ -12,9 +13,12 @@ export function MoodCheckInAction({
isSaving,
label,
onPress,
+ testID,
}: MoodCheckInActionProps) {
return (
void;
+ testID?: string;
};
export function MoodOption({
@@ -14,9 +15,11 @@ export function MoodOption({
isSelected,
mood,
onPress,
+ testID,
}: MoodOptionProps) {
return (
void;
+ optionTestIDPrefix?: string;
selectedMoodId: MoodId | null;
+ testID?: string;
};
export function MoodSpectrumSelector({
disabled = false,
moods,
onSelectMood,
+ optionTestIDPrefix,
selectedMoodId,
+ testID,
}: MoodSpectrumSelectorProps) {
return (
@@ -30,6 +35,11 @@ export function MoodSpectrumSelector({
key={mood.id}
mood={mood}
onPress={() => onSelectMood(mood.id)}
+ testID={
+ optionTestIDPrefix
+ ? `${optionTestIDPrefix}-${mood.id}-button`
+ : undefined
+ }
/>
))}
diff --git a/components/insights/insights-screen.tsx b/components/insights/insights-screen.tsx
index d30bafb..8fc23e9 100644
--- a/components/insights/insights-screen.tsx
+++ b/components/insights/insights-screen.tsx
@@ -2,7 +2,7 @@ import { useAuth } from "@clerk/expo";
import { LinearGradient as ExpoLinearGradient } from "expo-linear-gradient";
import { Link, useRouter, type Href } from "expo-router";
import { StatusBar } from "expo-status-bar";
-import { BarChart3, CalendarDays, Sparkles } from "lucide-react-native";
+import { BarChart3, CalendarDays, Lock, Sparkles } from "lucide-react-native";
import { useEffect, useMemo, useState } from "react";
import {
Pressable,
@@ -52,6 +52,7 @@ import {
import { useAIInsightReport } from "@/hooks/useAIInsightReport";
import type { UseAIInsightReportResult } from "@/hooks/useAIInsightReport";
import { useDelayedVisibility } from "@/hooks/useDelayedVisibility";
+import { useFeatureAccess } from "@/hooks/useFeatureAccess";
import { deriveInsights } from "@/lib/insights/deriveInsights";
import {
getInsightDateRange,
@@ -160,6 +161,8 @@ export function InsightsScreen() {
const monthlyReportState = useAIInsightReport(monthlyPeriod, {
enabled: localDataHasHydrated,
});
+ const advancedInsightsAccess = useFeatureAccess("advanced_insights", userId);
+ const monthlyReportAccess = useFeatureAccess("monthly_report", userId);
const insights = useMemo(
() =>
getLocalInsights({
@@ -275,6 +278,7 @@ export function InsightsScreen() {
/>
@@ -391,9 +396,20 @@ export function InsightsScreen() {
AI-generated reflections from your saved reports
- {aiInsightCards.map((card) => (
-
- ))}
+ {advancedInsightsAccess.allowed ? (
+ aiInsightCards.map((card) => (
+
+ ))
+ ) : (
+
+ router.push({
+ pathname: "/paywall",
+ params: { feature: "advanced_insights" },
+ } as unknown as Href)
+ }
+ />
+ )}
@@ -411,6 +427,13 @@ export function InsightsScreen() {
/>
+ router.push({
+ pathname: "/paywall",
+ params: { feature: "monthly_report" },
+ } as unknown as Href)
+ }
reportState={monthlyReportState}
/>
@@ -425,9 +448,13 @@ export function InsightsScreen() {
}
function ReflectionReportCard({
+ isLocked = false,
+ onLockedPress,
period,
reportState,
}: {
+ isLocked?: boolean;
+ onLockedPress?: () => void;
period: ReportPeriod;
reportState: UseAIInsightReportResult;
}) {
@@ -444,10 +471,11 @@ function ReflectionReportCard({
hasEnoughEntries,
isGenerating: reportState.isGenerating,
isStale: reportState.isStale,
+ isLocked,
legacyReportAvailable: reportState.legacyReportAvailable,
reportExists: Boolean(reportState.report),
});
- const buttonLabel = reportState.report ? "View" : "Open";
+ const buttonLabel = isLocked ? "View Pro" : reportState.report ? "View" : "Open";
const buttonScale = useSharedValue(1);
const animatedButtonStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
@@ -475,6 +503,7 @@ function ReflectionReportCard({
return (
@@ -518,16 +547,13 @@ function ReflectionReportCard({
-
+ {isLocked ? (
@@ -551,7 +577,44 @@ function ReflectionReportCard({
-
+ ) : (
+
+
+
+
+ {buttonLabel}
+
+
+
+
+ )}
);
}
@@ -560,15 +623,21 @@ function getReportCardStatus({
hasEnoughEntries,
isGenerating,
isStale,
+ isLocked,
legacyReportAvailable,
reportExists,
}: {
hasEnoughEntries: boolean;
isGenerating: boolean;
isStale: boolean;
+ isLocked: boolean;
legacyReportAvailable: boolean;
reportExists: boolean;
}) {
+ if (isLocked) {
+ return "Pro";
+ }
+
if (isGenerating) {
return "Generating";
}
@@ -588,6 +657,41 @@ function getReportCardStatus({
return reportExists ? "Ready" : "Not generated";
}
+function LockedInsightCard({ onPress }: { onPress: () => void }) {
+ return (
+
+
+
+
+
+
+
+ Unlock deeper patterns with DearDiary Pro
+
+
+ Advanced mood and writing insights appear here with Pro reports.
+
+
+
+
+
+ Upgrade
+
+
+
+ );
+}
+
type LocalInsights = {
cards: InsightCard[];
moodJourney: MoodJourneyPoint[];
diff --git a/components/insights/report/ReportScreenStates.tsx b/components/insights/report/ReportScreenStates.tsx
index aa8c4a7..2f92b32 100644
--- a/components/insights/report/ReportScreenStates.tsx
+++ b/components/insights/report/ReportScreenStates.tsx
@@ -62,6 +62,7 @@ export function ReportShell({
export function LoadingState({ periodType }: { periodType: ReportPeriodType }) {
return (
@@ -91,6 +92,7 @@ export function UpdatingBanner({
}) {
return (
@@ -118,6 +120,7 @@ export function ErrorBanner({
}) {
return (
{onRetry ? (
);
@@ -327,6 +337,7 @@ export function OlderFormatState({
disabled={disabled}
label="Generate Visual Reflection"
onPress={onGenerate}
+ testID={`${periodType}-report-generate-button`}
/>
void;
+ testID?: string;
}) {
const pressValue = useMemo(() => new Animated.Value(0), []);
const scale = pressValue.interpolate({
@@ -366,6 +379,8 @@ export function PrimaryButton({
return (
@@ -73,6 +74,7 @@ export function EntryAIReflectionCard({
return (
@@ -95,6 +97,7 @@ export function EntryAIReflectionCard({
{error ? (
@@ -148,32 +155,46 @@ export function EntryAIReflectionCard({
) : null}
-
+
-
+
{error ? (
}
isLoading={isGenerating}
@@ -201,9 +225,11 @@ export function EntryAIReflectionCard({
}
function ReflectionTextSection({
+ testID,
title,
value,
}: {
+ testID?: string;
title: string;
value: string | null;
}) {
@@ -212,7 +238,7 @@ function ReflectionTextSection({
}
return (
-
+
{title}
@@ -228,10 +254,14 @@ function ReflectionTextSection({
}
function ReflectionChipSection({
+ chipTestIdPrefix,
items,
+ testID,
title,
}: {
+ chipTestIdPrefix?: string;
items: string[];
+ testID?: string;
title: string;
}) {
if (items.length === 0) {
@@ -239,13 +269,18 @@ function ReflectionChipSection({
}
return (
-
+
{title}
{items.map((item) => (
void;
+ testID?: string;
}) {
return (
);
}
+
+function normalizeTestIdSegment(value: string) {
+ return (
+ value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "theme"
+ );
+}
diff --git a/components/journal-editor/journal-editor-screen.tsx b/components/journal-editor/journal-editor-screen.tsx
index f365f68..5bab322 100644
--- a/components/journal-editor/journal-editor-screen.tsx
+++ b/components/journal-editor/journal-editor-screen.tsx
@@ -1,6 +1,6 @@
import * as Haptics from "expo-haptics";
import { LinearGradient } from "expo-linear-gradient";
-import { useLocalSearchParams, useRouter } from "expo-router";
+import { type Href, useLocalSearchParams, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import LottieView from "lottie-react-native";
import { Check, ChevronLeft, Sparkles, Trash2, X } from "lucide-react-native";
@@ -35,6 +35,7 @@ import { useAutoSync } from "@/hooks/useAutoSync";
import { useConnectivity } from "@/hooks/useConnectivity";
import { useDelayedVisibility } from "@/hooks/useDelayedVisibility";
import { useEntryReflection } from "@/hooks/useEntryReflection";
+import { useFeatureAccess } from "@/hooks/useFeatureAccess";
import { normalizeAppError } from "@/lib/errors/normalizeAppError";
import { reportAppError } from "@/lib/errors/reportAppError";
import { formatTagLabel, normalizeTag, normalizeTags } from "@/lib/tags";
@@ -44,6 +45,7 @@ import {
useJournalStore,
} from "@/store/journal-store";
import { useEntryReflectionStore } from "@/store/useEntryReflectionStore";
+import { useAIUsageStore } from "@/store/useAIUsageStore";
import { useSyncStore } from "@/store/useSyncStore";
import type { ConnectivityStatus } from "@/types/connectivity";
import type {
@@ -165,6 +167,10 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
entryUpdatedAt: entry?.updatedAt ?? null,
userId: activeUserId,
});
+ const entryReflectionAccess = useFeatureAccess(
+ "entry_reflection",
+ activeUserId,
+ );
useEffect(() => {
if (!entry) {
@@ -401,19 +407,59 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
}
async function handleGenerateReflection() {
+ if (!canUseEntryReflectionAccess()) {
+ return;
+ }
+
const canGenerate = await syncEntryBeforeReflection();
if (canGenerate) {
- await entryReflection.generate();
+ const generated = await entryReflection.generate();
+ if (generated && activeUserId) {
+ useAIUsageStore
+ .getState()
+ .incrementMonthlyUsage(activeUserId, "entry_reflection");
+ }
}
}
async function handleConfirmedRegenerateReflection() {
+ if (!canUseEntryReflectionAccess()) {
+ return;
+ }
+
const canRegenerate = await syncEntryBeforeReflection();
if (canRegenerate) {
- await entryReflection.regenerate();
+ const regenerated = await entryReflection.regenerate();
+ if (regenerated && activeUserId) {
+ useAIUsageStore
+ .getState()
+ .incrementMonthlyUsage(activeUserId, "entry_reflection");
+ }
+ }
+ }
+
+ function canUseEntryReflectionAccess() {
+ if (entryReflectionAccess.allowed) {
+ return true;
}
+
+ if (entryReflectionAccess.reason === "Pro_fair_use_exhausted") {
+ showDialog({
+ confirmText: "OK",
+ message:
+ "You've reached this month's DearDiary Pro fair-use limit for AI reflections. Please try again next month.",
+ title: "Monthly AI reflection limit reached",
+ });
+ return false;
+ }
+
+ router.push({
+ pathname: "/paywall",
+ params: { feature: "entry_reflection" },
+ } as unknown as Href);
+ return false;
}
function handleButtonPressOut(scaleValue: Animated.Value) {
@@ -458,6 +504,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
@@ -468,6 +515,8 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
This journal entry may have been deleted.
router.replace("/journal-history")}
@@ -490,6 +539,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
{isEditing ? (
@@ -632,6 +687,8 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
return (
{tags.map((tag) => (
))}
setIsTagInputVisible(true)}
onPressIn={() => handleButtonPressIn(addTagButtonScale)}
@@ -719,6 +779,8 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
{!hasPromptTitle ? (
<>
{
@@ -734,6 +796,8 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
) : null}
setIsTagInputVisible(false)}
visible={isTagInputVisible}
@@ -797,6 +863,16 @@ function isEntryType(value: string | undefined): value is EntryType {
return entryTypes.includes(value as EntryType);
}
+function normalizeTestIdSegment(value: string) {
+ return (
+ value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "tag"
+ );
+}
+
function AnimatedMoodEmoji({
isSelected,
moodId,
diff --git a/components/journal-history/journal-calendar-view.tsx b/components/journal-history/journal-calendar-view.tsx
index 1273fbb..b491e5a 100644
--- a/components/journal-history/journal-calendar-view.tsx
+++ b/components/journal-history/journal-calendar-view.tsx
@@ -176,6 +176,7 @@ export function JournalCalendarView({
>
router.push(newJournalEntryHref)}
shadow="0 2px 7px rgba(39, 39, 42, 0.18)"
@@ -245,6 +247,7 @@ export function JournalHistoryScreen() {
>
-
+
{hydrationError ? (
router.push(newJournalEntryHref)}
@@ -542,7 +549,8 @@ function TimelineEntry({
}) {
return (
@@ -55,6 +58,7 @@ export function OnboardingProgressHeader({
{onSkipPress ? (
[monthlyPackage, yearlyPackage].filter(isPurchasesPackage),
+ [monthlyPackage, yearlyPackage],
+ );
+ const [selectedPackage, setSelectedPackage] =
+ useState(yearlyPackage ?? monthlyPackage ?? null);
+ const [statusMessage, setStatusMessage] = useState(null);
+ const [isPurchasing, setIsPurchasing] = useState(false);
+ const [isRestoring, setIsRestoring] = useState(false);
+ const selectedPackageIsAvailable =
+ selectedPackage &&
+ availablePackages.some(
+ (availablePackage) =>
+ availablePackage.identifier === selectedPackage.identifier &&
+ availablePackage.product.identifier === selectedPackage.product.identifier,
+ );
+ const continueDisabled =
+ !isConfigured || !selectedPackageIsAvailable || isPurchasing || isRestoring;
+
+ useEffect(() => {
+ const defaultPackage = yearlyPackage ?? monthlyPackage ?? null;
+
+ setSelectedPackage((currentPackage) => {
+ if (!defaultPackage) {
+ return null;
+ }
+
+ if (
+ currentPackage &&
+ availablePackages.some(
+ (availablePackage) =>
+ availablePackage.identifier === currentPackage.identifier &&
+ availablePackage.product.identifier ===
+ currentPackage.product.identifier,
+ )
+ ) {
+ return currentPackage;
+ }
+
+ return defaultPackage;
+ });
+ }, [availablePackages, monthlyPackage, yearlyPackage]);
+
+ async function handleContinue() {
+ if (!selectedPackage || continueDisabled) {
+ return;
+ }
+
+ setIsPurchasing(true);
+ setStatusMessage(null);
+
+ try {
+ const customerInfo = await purchasePackage(selectedPackage);
+
+ if (hasProEntitlement(customerInfo)) {
+ showDialog({
+ confirmText: "Done",
+ message:
+ "DearDiary Pro is active. Return to the feature and try again when you are ready.",
+ title: "Pro unlocked",
+ variant: "success",
+ });
+ closePaywall();
+ return;
+ }
+
+ setStatusMessage("Purchase completed, but Pro is not active yet.");
+ } catch (purchaseError) {
+ setStatusMessage(getErrorMessage(purchaseError));
+ } finally {
+ setIsPurchasing(false);
+ }
+ }
+
+ async function handleRestore() {
+ if (!isConfigured || isPurchasing || isRestoring) {
+ return;
+ }
+
+ setIsRestoring(true);
+ setStatusMessage(null);
+
+ try {
+ const customerInfo = await restorePurchases();
+
+ if (hasProEntitlement(customerInfo)) {
+ showDialog({
+ confirmText: "Done",
+ message: "Subscription restored successfully.",
+ title: "DearDiary Pro active",
+ variant: "success",
+ });
+ closePaywall();
+ return;
+ }
+
+ setStatusMessage("No active subscription was found.");
+ } catch (restoreError) {
+ setStatusMessage(getErrorMessage(restoreError));
+ } finally {
+ setIsRestoring(false);
+ }
+ }
+
+ function closePaywall() {
+ if (router.canGoBack()) {
+ router.back();
+ return;
+ }
+
+ router.replace("/profile-tab");
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ DearDiary Pro
+
+
+
+
+
+
+
+
+
+ DearDiary Pro
+
+
+ Go deeper with AI-powered reflections, emotional patterns, weekly
+ reports, monthly summaries, and smarter insights.
+
+ {feature ? (
+
+
+ {getFeaturePrompt(feature)}
+
+
+ ) : null}
+
+
+
+
+
+
+ Included with Pro
+
+
+
+
+
+
+ {monthlyPackage ? (
+ setSelectedPackage(monthlyPackage)}
+ planPackage={monthlyPackage}
+ title="Monthly"
+ />
+ ) : null}
+ {yearlyPackage ? (
+ setSelectedPackage(yearlyPackage)}
+ planPackage={yearlyPackage}
+ title="Yearly"
+ />
+ ) : null}
+
+
+ {!isConfigured ? (
+
+ ) : !offering || availablePackages.length === 0 ? (
+
+ ) : error ? (
+
+ ) : null}
+
+ {statusMessage ? (
+
+ ) : null}
+
+
+ {isPurchasing ? (
+
+ ) : null}
+
+ Continue
+
+
+
+
+
+ {isRestoring ? (
+
+ ) : (
+
+ )}
+
+ Restore purchases
+
+
+
+
+ Maybe later
+
+
+
+
+
+
+ Subscription renews automatically unless cancelled through the App
+ Store or Google Play.
+
+
+
+
+
+ Terms
+
+
+
+
+
+
+ Privacy Policy
+
+
+
+
+
+
+ {isLoading && !isPurchasing && !isRestoring ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+function UnavailableMessage({
+ message,
+ testID,
+}: {
+ message: string;
+ testID?: string;
+}) {
+ return (
+
+
+ {message}
+
+
+ );
+}
+
+function findPackage(
+ packages: PurchasesPackage[] | undefined,
+ interval: "monthly" | "yearly",
+) {
+ return packages?.find((candidate) => {
+ const identifier = candidate.identifier.toLowerCase();
+ const productIdentifier = candidate.product.identifier.toLowerCase();
+ const period = candidate.product.subscriptionPeriod;
+
+ if (interval === "monthly") {
+ return (
+ identifier.includes("monthly") ||
+ productIdentifier.includes("monthly") ||
+ period === "P1M"
+ );
+ }
+
+ return (
+ identifier.includes("yearly") ||
+ identifier.includes("annual") ||
+ productIdentifier.includes("yearly") ||
+ productIdentifier.includes("annual") ||
+ period === "P1Y"
+ );
+ }) ?? null;
+}
+
+function isPurchasesPackage(
+ value: PurchasesPackage | null | undefined,
+): value is PurchasesPackage {
+ return Boolean(value);
+}
+
+function getErrorMessage(error: unknown) {
+ return error instanceof Error
+ ? error.message
+ : "We could not complete the request. Please try again.";
+}
+
+function getFeaturePrompt(feature: string) {
+ if (feature === "entry_reflection") {
+ return "You've used your free AI reflections for this month. Upgrade to DearDiary Pro for more reflections, reports, and insights.";
+ }
+
+ if (feature === "ai_chat") {
+ return "You've used your free AI Chat messages for this month. Upgrade to DearDiary Pro for more AI reflection support.";
+ }
+
+ if (feature === "monthly_report") {
+ return "Monthly AI reports are included with DearDiary Pro.";
+ }
+
+ if (feature === "advanced_insights") {
+ return "Unlock deeper patterns with DearDiary Pro.";
+ }
+
+ return "Upgrade to DearDiary Pro for more AI reflections, reports, and insights.";
+}
diff --git a/components/paywall/PlanCard.tsx b/components/paywall/PlanCard.tsx
new file mode 100644
index 0000000..c956884
--- /dev/null
+++ b/components/paywall/PlanCard.tsx
@@ -0,0 +1,64 @@
+import { Pressable, Text, View } from "react-native";
+import type { PurchasesPackage } from "react-native-purchases";
+
+type PlanCardProps = {
+ accessibilityLabel?: string;
+ billingLabel: string;
+ isRecommended?: boolean;
+ isSelected: boolean;
+ onPress: () => void;
+ planPackage: PurchasesPackage;
+ testID?: string;
+ title: string;
+};
+
+export function PlanCard({
+ accessibilityLabel,
+ billingLabel,
+ isRecommended = false,
+ isSelected,
+ onPress,
+ planPackage,
+ testID,
+ title,
+}: PlanCardProps) {
+ return (
+
+
+
+
+ {title}
+
+
+ {billingLabel}
+
+
+ {isRecommended ? (
+
+
+ Best value
+
+
+ ) : null}
+
+
+ {planPackage.product.priceString}
+
+
+ );
+}
diff --git a/components/paywall/PremiumFeatureList.tsx b/components/paywall/PremiumFeatureList.tsx
new file mode 100644
index 0000000..41a67d5
--- /dev/null
+++ b/components/paywall/PremiumFeatureList.tsx
@@ -0,0 +1,21 @@
+import { Check } from "lucide-react-native";
+import { Text, View } from "react-native";
+
+import { premiumBenefits } from "@/data/paywall";
+
+export function PremiumFeatureList() {
+ return (
+
+ {premiumBenefits.map((benefit) => (
+
+
+
+
+
+ {benefit}
+
+
+ ))}
+
+ );
+}
diff --git a/components/profile/notification-settings-screen.tsx b/components/profile/notification-settings-screen.tsx
index b69736e..9e74c21 100644
--- a/components/profile/notification-settings-screen.tsx
+++ b/components/profile/notification-settings-screen.tsx
@@ -176,6 +176,7 @@ export function NotificationSettingsScreen() {
/>
Use notifications?
-
+
{hasHydrated
? hydrationError
? "Saved reminder settings need attention"
@@ -225,6 +230,8 @@ export function NotificationSettingsScreen() {
) : (
@@ -298,15 +307,18 @@ function ReminderTimeRow({
disabled = false,
label,
onPress,
+ testID,
time,
}: {
disabled?: boolean;
label: string;
onPress: () => void;
+ testID?: string;
time: string;
}) {
return (
diff --git a/components/profile/profile-screen.tsx b/components/profile/profile-screen.tsx
index 21698f2..a555b4b 100644
--- a/components/profile/profile-screen.tsx
+++ b/components/profile/profile-screen.tsx
@@ -1,4 +1,4 @@
-import { useAuth, useClerk, useUser } from "@clerk/expo";
+import { useAuth, useUser } from "@clerk/expo";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import Constants from "expo-constants";
import { LinearGradient } from "expo-linear-gradient";
@@ -7,6 +7,7 @@ import { StatusBar } from "expo-status-bar";
import { useMemo, useState } from "react";
import {
ActivityIndicator,
+ Linking,
Pressable,
ScrollView,
Text,
@@ -31,7 +32,9 @@ import {
type ProfileStat,
} from "@/data/profile";
import { useAppDialog } from "@/hooks/useAppDialog";
+import { useAppSignOut } from "@/hooks/useAppSignOut";
import { useConnectivity } from "@/hooks/useConnectivity";
+import { useSubscription } from "@/hooks/useSubscription";
import { useSyncStatus } from "@/hooks/useSyncStatus";
import { getAccountDeletionFailureMessage } from "@/lib/account/accountDeletionErrors";
import { deleteCurrentAccount } from "@/lib/account/accountDeletionService";
@@ -43,7 +46,6 @@ import {
} from "@/lib/exportJournal";
import { reportAppError } from "@/lib/errors/reportAppError";
import { getPublicEnvironment } from "@/lib/environment";
-import { setSupabaseAccessTokenProvider } from "@/lib/supabase";
import { requestSync } from "@/lib/sync/requestSync";
import {
retryJournalStoreHydration,
@@ -66,6 +68,9 @@ const achievementsHref = "/achievements" as Href;
const privacySettingsHref = "/settings/privacy" as Href;
const cloudSyncItemLabel = "Backup & Sync Data";
const deleteAccountItemLabel = "Delete My Data and Account";
+const premiumMembershipItemLabel = "Premium Membership";
+const proActiveItemLabel = "DearDiary Pro active";
+const upgradeProItemLabel = "Upgrade to DearDiary Pro";
const appEnvironment = getPublicEnvironment()?.appEnvironment;
const moodLabels: Record = {
@@ -89,9 +94,11 @@ const moodEmoji: Record = {
export function ProfileScreen() {
const insets = useSafeAreaInsets();
const { getToken } = useAuth();
- const { signOut } = useClerk();
const { user } = useUser();
const { showDialog } = useAppDialog();
+ const signOutApp = useAppSignOut();
+ const { customerInfo, isConfigured: subscriptionsConfigured, isPro } =
+ useSubscription();
const connectivity = useConnectivity();
const entries = useJournalStore((state) => state.entries);
const hasHydrated = useJournalHydrationStore(
@@ -100,7 +107,6 @@ export function ProfileScreen() {
const hydrationError = useJournalHydrationStore(
(state) => state.hydrationError,
);
- const setActiveUserId = useJournalStore((state) => state.setActiveUserId);
const achievementHasHydrated = useAchievementHydrationStore(
(state) => state.hasHydrated,
);
@@ -157,8 +163,26 @@ export function ProfileScreen() {
}, [entries, user?.id]);
const accountMenuItems = useMemo(
() =>
- accountItems.filter((item) => item.label !== cloudSyncItemLabel),
- [],
+ accountItems
+ .filter((item) => item.label !== cloudSyncItemLabel)
+ .map((item) => {
+ if (item.label !== premiumMembershipItemLabel) {
+ return item;
+ }
+
+ return {
+ ...item,
+ badge: isPro ? "ACTIVE" : "PRO",
+ label: isPro ? proActiveItemLabel : upgradeProItemLabel,
+ subtitle: isPro
+ ? "Manage your store subscription"
+ : "Unlock deeper AI reflections and reports",
+ testID: isPro
+ ? "profile-manage-subscription-button"
+ : "profile-upgrade-plus-button",
+ };
+ }),
+ [isPro],
);
async function handleSignOut() {
@@ -167,16 +191,10 @@ export function ProfileScreen() {
}
setIsSigningOut(true);
- setActiveUserId(null);
- if (user?.id) {
- useSyncStore.getState().clearSyncStateForUser(user.id);
- }
try {
- await signOut();
- setSupabaseAccessTokenProvider(null);
+ await signOutApp(user?.id);
} catch {
- setActiveUserId(user?.id ?? null);
showDialog({
confirmText: "OK",
message: "We could not sign you out. Please try again.",
@@ -345,7 +363,7 @@ export function ProfileScreen() {
const result = await deleteCurrentAccount({
confirmationPhrase: deleteConfirmationText,
getToken,
- signOut: () => signOut(),
+ signOut: () => signOutApp(userId),
userId,
});
@@ -442,6 +460,41 @@ export function ProfileScreen() {
});
}
+ async function handleSubscriptionPress() {
+ if (!isPro) {
+ router.push({
+ pathname: "/paywall",
+ params: { feature: "profile" },
+ } as unknown as Href);
+ return;
+ }
+
+ const managementUrl = customerInfo?.managementURL;
+
+ if (managementUrl) {
+ const canOpen = await Linking.canOpenURL(managementUrl);
+
+ if (canOpen) {
+ try {
+ await Linking.openURL(managementUrl);
+ return;
+ } catch (error) {
+ reportAppError(error, {
+ feature: "subscription",
+ operation: "open_management_url",
+ });
+ }
+ }
+ }
+
+ showDialog({
+ confirmText: "OK",
+ message:
+ "Manage or cancel your subscription from the App Store or Google Play subscriptions page.",
+ title: "Manage subscription",
+ });
+ }
+
async function handleExportJournal(format: "json" | "markdown") {
if (isExportingJournal) {
return;
@@ -542,6 +595,7 @@ export function ProfileScreen() {
/>
showComingSoon("Settings")}
shadow="0 2px 6px rgba(39, 39, 42, 0.16)"
@@ -755,6 +811,25 @@ export function ProfileScreen() {
return;
}
+ if (
+ item.label === upgradeProItemLabel ||
+ item.label === proActiveItemLabel ||
+ item.label === premiumMembershipItemLabel
+ ) {
+ if (!subscriptionsConfigured && !isPro) {
+ showDialog({
+ confirmText: "OK",
+ message:
+ "DearDiary Pro subscriptions are not configured for this build yet.",
+ title: "Subscriptions unavailable",
+ });
+ return;
+ }
+
+ void handleSubscriptionPress();
+ return;
+ }
+
if (item.label === deleteAccountItemLabel) {
handleDeleteAccountPress();
return;
@@ -786,6 +861,8 @@ export function ProfileScreen() {
(
@@ -950,7 +1030,10 @@ function DeleteAccountConfirmationCard({
Final confirmation
-
+
This action cannot be undone. Export your journal first if you want
to keep a copy.
@@ -976,6 +1059,8 @@ function DeleteAccountConfirmationCard({
{deletionRequestInFlight ? (
-
+
@@ -1018,6 +1107,9 @@ function DeleteAccountConfirmationCard({
void;
onSecondaryAction?: () => void;
secondaryActionLabel?: string;
+ secondaryActionTestID?: string;
+ testID?: string;
title: string;
};
export function ScreenEmptyState({
actionLabel,
+ actionTestID,
compact = false,
icon,
message,
onAction,
onSecondaryAction,
secondaryActionLabel,
+ secondaryActionTestID,
+ testID,
title,
}: ScreenEmptyStateProps) {
return (
{actionLabel && onAction ? (
void;
onClose: () => void;
+ submitTestID?: string;
visible: boolean;
};
@@ -27,8 +31,12 @@ const slideInBottomTiming = {
} as const;
export function TagInputModal({
+ cancelTestID,
+ closeTestID,
+ inputTestID,
onAddTag,
onClose,
+ submitTestID,
visible,
}: TagInputModalProps) {
const inputRef = useRef(null);
@@ -80,6 +88,7 @@ export function TagInputModal({
className="flex-1 justify-end bg-black/30"
>
-button` | Home | Mood radio button | Select a mood |
+| `home-mood-save-button` | Home | Mood save button | Save mood check-in |
+| `home-ai-prompt-card`, `home-ai-prompt-text`, `home-ai-prompt-open-button` | Home | Daily prompt card | Open prompt journaling |
+| `home-writing-streak-card` | Home | Streak card | Verify streak area |
+| `home-morning-intention-card`, `home-morning-intention-open-button` | Home | Morning intention card/action | Open or create intention |
+| `home-recent-entries-section`, `home-recent-entry-card-` | Home | Recent entries | Open recent entry |
+| `journal-editor-screen` | Journal editor | Screen | Detect editor |
+| `journal-editor-title-input`, `journal-editor-body-input` | Journal editor | Text inputs | Write entry title/body |
+| `journal-editor-save-button`, `journal-editor-delete-button`, `journal-editor-back-button` | Journal editor | Main actions | Save/delete/go back |
+| `journal-editor-sync-status` | Journal editor | Save status text | Read sync/local state |
+| `journal-editor-mood--button` | Journal editor | Mood chip | Set entry mood |
+| `journal-editor-add-tag-button`, `journal-editor-tag-input`, `journal-editor-tag-submit-button` | Journal editor | Tag controls | Add a tag |
+| `journal-editor-tag-chip-` | Journal editor | Tag chip | Remove a visible tag |
+| `entry-reflection-screen` | Entry reflection | Reflection card | Detect AI reflection area |
+| `entry-reflection-generate-button`, `entry-reflection-regenerate-button` | Entry reflection | AI actions | Generate/update reflection |
+| `entry-reflection-loading`, `entry-reflection-error-message` | Entry reflection | State messages | Wait/assert reflection state |
+| `entry-reflection-summary-section`, `entry-reflection-emotions-section`, `entry-reflection-themes-section`, `entry-reflection-question-section`, `entry-reflection-next-step-section` | Entry reflection | Result sections | Verify generated sections |
+| `entry-reflection-theme-chip-` | Entry reflection | Theme chip | Verify visible theme chip |
+| `history-screen` | History | Screen | Detect history |
+| `history-search-input` | History | Search input | Search entries |
+| `history-filter-list`, `history-mood-filter--button` | History | Filters | Filter by mood |
+| `history-entry-list`, `history-entry-card-` | History | Entry list/card | Open entry without content-based IDs |
+| `history-empty-state` | History | Empty state | Assert no entries |
+| `calendar-prev-month-button`, `calendar-next-month-button`, `calendar-today-button` | Calendar | Month controls | Navigate calendar |
+| `calendar-day-button-` | Calendar | Calendar day | Select date |
+| `ai-chat-screen` | AI Chat | Screen | Detect chat |
+| `ai-chat-message-list` | AI Chat | Message list | Verify list exists |
+| `ai-chat-message-input`, `ai-chat-send-button` | AI Chat | Composer | Send message |
+| `ai-chat-clear-button`, `ai-chat-jump-latest-button`, `ai-chat-emoji-toggle-button` | AI Chat | Chat actions | Manage chat view |
+| `ai-chat-loading-indicator`, `ai-chat-thinking-indicator`, `ai-chat-error-message` | AI Chat | State messages | Wait/assert chat state |
+| `assistant-message-` | AI Chat | Assistant message renderer | Target generated assistant message |
+| `insights-screen` | Insights | Screen | Detect insights |
+| `insights-weekly-report-card`, `insights-monthly-report-card` | Insights | Report cards | Verify report entry points |
+| `weekly-report-open-button`, `monthly-report-open-button` | Insights | Report open buttons | Open reports |
+| `weekly-report-generate-button`, `monthly-report-generate-button` | Reports | Generate buttons | Generate report |
+| `weekly-report-content`, `monthly-report-content` | Reports | Report scroll content | Verify report view |
+| `report-loading-indicator`, `report-error-message`, `report-retry-button` | Reports | State/actions | Handle report states |
+| `premium-locked-card`, `insights-advanced-upgrade-button`, `insights-monthly-report-upgrade-button` | Insights premium | Locked CTAs | Open paywall |
+| `paywall-screen` | Paywall | Screen | Detect paywall |
+| `paywall-monthly-plan-card`, `paywall-yearly-plan-card` | Paywall | Plan cards | Select plan |
+| `paywall-continue-button`, `paywall-restore-button`, `paywall-maybe-later-button` | Paywall | Actions | Purchase/restore/close |
+| `paywall-terms-link`, `paywall-privacy-link` | Paywall | Legal links | Open legal docs |
+| `paywall-error-message`, `paywall-status-message`, `paywall-loading-indicator` | Paywall | State messages | Assert state |
+| `profile-screen` | Profile | Screen | Detect profile |
+| `profile-settings-button`, `profile-signout-button` | Profile | Header/account actions | Open settings/sign out |
+| `profile-export-button`, `profile-backup-button` | Profile | Account rows | Export or sync data |
+| `profile-upgrade-plus-button`, `profile-manage-subscription-button` | Profile | Subscription row | Open paywall/manage subscription |
+| `profile-delete-account-button` | Profile | Destructive row | Reveal deletion confirmation |
+| `delete-account-screen`, `delete-account-confirm-input`, `delete-account-confirm-button`, `delete-account-cancel-button` | Account deletion | Confirmation controls | Test confirmation safeguards |
+| `delete-account-warning-text`, `delete-account-loading-indicator` | Account deletion | Warning/loading | Assert destructive flow state |
+| `settings-screen` | Settings/privacy | Screen | Detect settings |
+| `settings-app-lock-row`, `settings-notifications-row`, `settings-privacy-row`, `settings-theme-row` | Settings/profile | Rows | Navigate settings |
+| `profile-privacy-policy-link`, `profile-terms-link` | Settings/privacy | Legal rows | Open legal docs |
+| `app-lock-screen` | App Lock | Lock gate | Detect private lock |
+| `app-lock-pin-input`, `app-lock-unlock-button`, `app-lock-biometric-button`, `app-lock-error-message` | App Lock | Unlock controls | Unlock or assert failure |
+| `app-lock-setup-screen`, `app-lock-setup-pin-input`, `app-lock-confirm-pin-input`, `app-lock-enable-button` | App Lock setup | Setup controls | Enable App Lock |
+| `app-lock-biometric-toggle`, `app-lock-delay--button` | App Lock setup | Preferences | Configure lock options |
+| `notifications-settings-screen` | Notifications | Screen | Detect notifications |
+| `notifications-enable-toggle`, `notification-permission-status` | Notifications | Toggle/status | Enable and assert reminders |
+| `morning-reminder-time-button`, `evening-reminder-time-button` | Notifications | Reminder rows | Open time picker |
+| `reminder-save-button`, `reminder-cancel-button` | Notifications | Picker actions | Save/cancel time |
+
+Dynamic ID strategy:
+- Entry cards use `entry.id`.
+- Calendar days use existing date keys in `yyyy-mm-dd` form.
+- Mood buttons use typed mood IDs.
+- Tag and AI theme chips use normalized visible chip text.
+- No ID includes journal title, journal body, email, user ID, token, purchase token, or private AI response content.
diff --git a/docs/environment-matrix.md b/docs/environment-matrix.md
index 68cee0d..eb1a020 100644
--- a/docs/environment-matrix.md
+++ b/docs/environment-matrix.md
@@ -19,8 +19,11 @@ Reserved for a future Play Store AAB using production services. Production value
| `EXPO_PUBLIC_SUPABASE_URL` | Local ignored HTTPS URL | Approved non-production project URL; missing in EAS | Future production project URL; pending | Yes | Yes |
| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | Local ignored legacy anonymous key | Approved Preview anonymous/publishable key; missing in EAS | Future production anonymous/publishable key; pending | Yes | Yes |
| `EXPO_PUBLIC_ACCOUNT_DELETION_URL` | Optional public URL | Optional public Preview URL; missing in EAS | Future public URL; pending | Yes | No |
+| `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY` | Optional public Android SDK key | Preview RevenueCat Android public SDK key; missing in EAS | Production Android public SDK key; pending | Yes | No for core app, yes for purchases |
+| `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` | Optional public iOS SDK key | Preview RevenueCat iOS public SDK key when iOS is enabled | Production iOS public SDK key; pending | Yes | No for core app, yes for purchases |
| `OPENROUTER_API_KEY` | Backend secret storage | Backend secret storage | Backend secret storage | No | Backend only |
| `CLERK_SECRET_KEY` | Delete-account Edge Function only | Delete-account Edge Function only | Backend only | No | Backend only |
+| `REVENUECAT_SECRET_API_KEY` | AI Edge Functions only | AI Edge Functions only | AI Edge Functions only | No | Backend only |
| `SUPABASE_SERVICE_ROLE_KEY` / `SUPABASE_SECRET_KEY` | Edge Functions only | Edge Functions only | Backend only | No | Backend only |
## Rules
@@ -42,6 +45,8 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
EXPO_PUBLIC_SUPABASE_URL
EXPO_PUBLIC_SUPABASE_ANON_KEY
EXPO_PUBLIC_ACCOUNT_DELETION_URL (optional)
+EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY
+EXPO_PUBLIC_REVENUECAT_IOS_API_KEY (optional until iOS builds are enabled)
```
Re-run `eas env:list preview` without value-revealing flags, then run the pre-build checks before building.
diff --git a/docs/privacy.md b/docs/privacy.md
index aaec882..0d4a5c5 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -12,6 +12,8 @@ Visible placeholders are intentionally used:
The current drafts avoid unsupported claims such as end-to-end encryption, zero-knowledge encryption, HIPAA compliance, medical treatment, therapist-client confidentiality, or third-party-free processing.
+Subscription language now covers DearDiary Pro, RevenueCat, App Store and Google Play billing, purchase restoration, auto-renewal, store-managed cancellation, AI fair-use limits, and the fact that account deletion does not cancel store subscriptions.
+
## Deletion Architecture
1. User starts deletion from Profile with **Delete My Data and Account**.
@@ -31,6 +33,7 @@ Required Edge Function secrets:
SUPABASE_URL
SUPABASE_SERVICE_ROLE_KEY
CLERK_SECRET_KEY
+REVENUECAT_SECRET_API_KEY (AI subscription validation only)
```
`SUPABASE_ANON_KEY` or `SUPABASE_PUBLISHABLE_KEY` must also be available for the user-token auth probe.
diff --git a/docs/subscription-feature-gating-audit.md b/docs/subscription-feature-gating-audit.md
new file mode 100644
index 0000000..1e081ac
--- /dev/null
+++ b/docs/subscription-feature-gating-audit.md
@@ -0,0 +1,40 @@
+# Subscription Feature Gating Audit
+
+Audit date: 2026-07-12
+Baseline: `dev` at `583fbc5922d0d494e3e68e57d59d7fd7ff46dfd4`
+
+DearDiary Pro should unlock premium AI depth while core journaling, privacy, data access, account deletion, and local-first behavior stay free. Client gates are only UX helpers; server-side Supabase Edge Function checks must protect AI-costing provider calls.
+
+Supabase MCP was available in this Codex session. The live `dear-diary` project (`gnbdsmijnopmmyipwnri`) was inspected before applying subscription work; existing RLS policies use the Clerk JWT `sub` via `requesting_user_id()` or the equivalent `auth.jwt() ->> 'sub'` predicate. Before this pass, the live schema did not yet contain `subscription_status` or `ai_usage_ledger`.
+
+| Feature | Current location | Current behavior | New access rule | Client gate | Server gate | Notes |
+| ------- | ---------------- | ---------------- | --------------- | ----------- | ----------- | ----- |
+| Journal entry creation | `app/journal/new.tsx`, `components/journal-editor/journal-editor-screen.tsx`, `store/journal-store.ts` | Local-first journal CRUD with sync metadata. | Always free | No | Existing RLS/sync ownership only | Must never trigger paywall. |
+| Journal entry editing | `app/journal/[id].tsx`, `components/journal-editor/journal-editor-screen.tsx`, `store/journal-store.ts` | Local-first edits. | Always free | No | Existing RLS/sync ownership only | Must not depend on RevenueCat. |
+| Journal entry deletion | `components/journal-editor/journal-editor-screen.tsx`, `store/journal-store.ts` | Soft-delete/local sync behavior. | Always free | No | Existing RLS/sync ownership only | Must not depend on RevenueCat. |
+| Mood logging | `components/home/mood/*`, `store/useMoodLogStore.ts` | Mood check-ins and mood-derived local insights. | Always free | No | Existing RLS/sync ownership only | Mood patterns from local data stay free; AI interpretation can be Pro. |
+| Manual tags | `components/tags/tag-input-modal.tsx`, `store/journal-store.ts`, `lib/tags.ts` | User-managed tags persist with entries. | Always free | No | Existing RLS/sync ownership only | AI-generated themes are tied to reflection usage. |
+| History | `components/journal-history/journal-history-screen.tsx`, `components/journal-history/journal-calendar-view.tsx` | Local journal history and calendar views. | Always free | No | Existing RLS/sync ownership only | No paywall. |
+| Calendar | `components/journal-history/journal-calendar-view.tsx`, `lib/calendar/*` | Activity/mood calendar from local entries. | Always free | No | Existing RLS/sync ownership only | No paywall. |
+| Basic search/filter | `components/journal-history/journal-history-screen.tsx` | Local search/filter over entries. | Always free | No | Existing RLS/sync ownership only | Semantic search is not currently implemented. |
+| Writing streak | `lib/achievements.ts`, `components/profile/profile-screen.tsx` | Derived locally from entries. | Always free | No | No | No paywall. |
+| Achievements | `components/achievements/*`, `store/useAchievementStore.ts` | Local achievements with sync state. | Always free | No | Existing sync ownership only | No paywall. |
+| Basic Insights | `components/insights/insights-screen.tsx`, `lib/insights/deriveInsights.ts` | Local mood journey, mood distribution, rhythm, recurring themes. | Always free | No | No | Keep local cards/charts visible. |
+| Notifications/reminders | `components/profile/notification-settings-screen.tsx`, `lib/notifications.ts` | Local notification preferences. | Always free | No | No | No paywall. |
+| App Lock, PIN, biometrics | `providers/AppLockProvider.tsx`, `components/app-lock/*`, `lib/security/*` | Privacy gate around app content. | Never paywalled | No | No | Paywall must stay behind App Lock. |
+| Basic export | `components/profile/profile-screen.tsx`, `lib/exportJournal.ts` | Markdown/JSON export. | Never paywalled | No | No | Account/data access requirement. |
+| Cloud sync | `components/sync/*`, `lib/sync/*`, `store/useSyncStore.ts` | Core local-first sync with Supabase. | Always free | No | Existing RLS/RPC ownership | Current product treats sync as core. |
+| Account deletion | `components/profile/profile-screen.tsx`, `lib/account/accountDeletionService.ts`, `supabase/functions/delete-account/index.ts` | Deletes remote/local data and Clerk account. | Never paywalled | No | Existing delete-account auth checks | Subscription cancellation remains store-managed. |
+| Privacy Policy and Terms | `app/legal/*`, `components/legal/legal-document-screen.tsx`, `docs/privacy.md` | Legal screens available through routes. | Never paywalled | No | No | Update copy for subscriptions. |
+| Sign out | `components/profile/profile-screen.tsx` | Clerk sign-out and local active user reset. | Never paywalled | No | No | Must clear RevenueCat customer state. |
+| AI Chat | `components/ai-chat/ai-chat-screen.tsx`, `lib/ai/remoteJournalAssistant.ts`, `supabase/functions/journal-ai-chat/index.ts` | Authenticated Supabase Edge Function; some low-cost intents return without provider calls. | Free quota: 10 provider-backed messages/month. Pro fair use: 300/month. | Yes, before send when local monthly usage says limit exhausted | Yes, before provider-backed AI calls | Do not count small talk/date/app capability/prompt-generation responses that skip provider calls. |
+| Entry AI reflections | `hooks/useEntryReflection.ts`, `components/journal-editor/entry-ai-reflection-card.tsx`, `lib/ai/entryReflectionService.ts`, `supabase/functions/reflect-on-entry/index.ts` | Authenticated Edge Function generates and stores one reflection per entry; themes are merged into local tags after success. | Free quota: 3 generated/regenerated reflections/month. Pro fair use: 100/month. | Yes, before generate/regenerate | Yes, before provider call | Existing reflection fetch/reuse remains readable. AI themes/tags included only when generation is allowed. |
+| AI-generated themes/tags | `hooks/useEntryReflection.ts`, `lib/entryReflectionThemeTags.ts` | Themes from generated reflection merge into entry tags. | Free only when using an available free reflection; Pro with reflection fair use. | Covered by entry reflection gate | Covered by entry reflection gate | Manual tags remain free. |
+| Weekly AI report | `hooks/useAIInsightReport.ts`, `components/insights/insights-screen.tsx`, `app/insights/report/[periodType].tsx`, `supabase/functions/generate-insight-report/index.ts` | Weekly report generation via Edge Function and `ai_insights` cache. | Free quota: 1 weekly preview/report per month. Pro: available for valid weeks. | Yes, before generation/regeneration | Yes, before provider call | Existing cached report viewing can remain available. |
+| Monthly AI report | Same report stack as weekly with `periodType=monthly` | Monthly generation via same Edge Function. | Pro-only, within fair use. | Yes | Yes | Free users see locked state/paywall. |
+| Advanced AI Insights | `components/insights/insights-screen.tsx` | AI cards are populated from weekly/monthly report narratives when reports exist. | Pro-only for advanced AI text beyond basic local insights. | Yes, locked card/CTA | Indirectly by report gates | Local charts/cards remain visible. |
+| Mood pattern analysis | `components/insights/insights-screen.tsx`, `supabase/functions/generate-insight-report/index.ts` | Basic local mood distributions; AI narrative if report exists. | Basic local free; AI pattern analysis Pro. | Yes for AI report/card surfaces | Yes via report generation | Do not hide local mood charts. |
+| Long-term summaries | `supabase/functions/journal-ai-chat/index.ts`, future report surfaces | AI Chat supports summary intents including broader ranges. | Pro-only/future premium AI feature after free chat quota. | Initial chat quota gate | Chat provider-call gate | Current UI has no dedicated long-term summary screen. |
+| Personalized AI suggestions | Entry reflections and reports produce suggestions/focus prompts. | AI suggestions come from paid AI surfaces. | Pro after free quotas. | Covered by reflection/report/chat gates | Covered by reflection/report/chat gates | No separate screen found. |
+| Daily Home prompts | `hooks/useDailyReflectionPrompt.ts`, `lib/ai/dailyReflectionPromptService.ts`, `supabase/functions/generate-daily-reflection-prompts/index.ts` | Authenticated Edge Function generates one prompt bundle per user/date with in-memory function cache and local fallback pool. | Temporarily left ungated; keep free, consider one bundle/day server rate limit. | No paywall | Existing auth plus function cache only | Task asks to consider cost risk. This pass keeps Home prompts free to avoid launch friction. |
+| Premium/Profile entry | `data/profile.ts`, `components/profile/profile-screen.tsx` | Existing "Premium Membership" row is filtered out and shows coming soon if enabled. | Show "Upgrade to DearDiary Pro" for free users; "DearDiary Pro active" and manage action for Pro. | Yes | No | Must not block Profile. |
diff --git a/docs/subscription-paywall-test.md b/docs/subscription-paywall-test.md
new file mode 100644
index 0000000..03ba6a2
--- /dev/null
+++ b/docs/subscription-paywall-test.md
@@ -0,0 +1,56 @@
+# Subscription Paywall Test Matrix
+
+Do not mark store purchase tests passed unless tested with sandbox/internal billing in a native build.
+
+| ID | Scenario | User type | Expected result | Actual | Status |
+| --- | -------- | --------- | --------------- | ------ | ------ |
+| 1 | App launches without RevenueCat configured in development with safe error. | Free | Core app works; paywall says subscriptions unavailable. | Not run on device. | Not tested |
+| 2 | App launches with RevenueCat configured. | Free | Core app works; offerings can load. | Not run with real keys. | Not tested |
+| 3 | Offerings load. | Free | Monthly/yearly packages render from RevenueCat. | Not run with real offerings. | Not tested |
+| 4 | Missing offerings show safe fallback. | Free | Paywall does not crash. | Source implemented. | Not tested |
+| 5 | Missing product does not crash paywall. | Free | Available package renders; missing package is hidden. | Source implemented. | Not tested |
+| 6 | Customer info loads after sign in. | Free/Pro | Clerk user ID is RevenueCat app user ID. | Not run with real keys. | Not tested |
+| 7 | Customer info clears after sign out. | Free/Pro | Previous entitlement is cleared. | Source implemented. | Not tested |
+| 8 | Monthly purchase success. | Free | Purchase completes and Pro unlocks. | Not run in sandbox/internal billing. | Not tested |
+| 9 | Yearly purchase success. | Free | Purchase completes and Pro unlocks. | Not run in sandbox/internal billing. | Not tested |
+| 10 | Purchase cancellation. | Free | Friendly cancellation message. | Not run in sandbox/internal billing. | Not tested |
+| 11 | Purchase failure. | Free | Friendly failure message. | Not run in sandbox/internal billing. | Not tested |
+| 12 | Restore active subscription. | Pro | Restore succeeds only if `DearDiary Pro` is active. | Not run in sandbox/internal billing. | Not tested |
+| 13 | Restore with no subscription. | Free | Shows no active subscription found. | Not run in sandbox/internal billing. | Not tested |
+| 14 | Already subscribed behavior. | Pro | Friendly state; Pro remains active. | Not run in sandbox/internal billing. | Not tested |
+| 15 | Manage subscription link. | Pro | Opens `customerInfo.managementURL` or store guidance. | Not run on device. | Not tested |
+| 16 | Pro entitlement unlocks. | Pro | `DearDiary Pro` active entitlement sets `isPro=true`. | Not run with real entitlement. | Not tested |
+| 17 | Expired entitlement locks. | Expired | Premium gates return to free behavior. | Not run with real entitlement. | Not tested |
+| 18 | User A Pro / User B Free account switching. | Mixed | No Pro state flash for User B. | Source resets customer info on auth change. | Not tested |
+| 19 | No entitlement flash during auth load. | Mixed | Subscription state clears while identifying. | Source implemented. | Not tested |
+| 20 | App restart preserves correct entitlement after refresh. | Mixed | RevenueCat refresh controls state. | Not run on device. | Not tested |
+| 21 | Free AI Chat first 10 messages allowed. | Free | Provider-backed messages allowed. | Not run against deployed function. | Not tested |
+| 22 | Free AI Chat 11th message shows paywall. | Free | Client/server deny and paywall opens. | Not run against deployed function. | Not tested |
+| 23 | Free first 3 reflections allowed. | Free | Reflections generate. | Not run against deployed function. | Not tested |
+| 24 | Free 4th reflection shows paywall. | Free | Client/server deny and paywall opens. | Not run against deployed function. | Not tested |
+| 25 | Free weekly report preview allowed. | Free | First monthly weekly report generation allowed. | Not run against deployed function. | Not tested |
+| 26 | Free monthly report locked. | Free | Monthly report opens Pro lock/paywall. | Source implemented. | Not tested |
+| 27 | Pro AI Chat allowed within fair use. | Pro | Up to 300 provider-backed messages/month. | Not run with real entitlement. | Not tested |
+| 28 | Pro reflections allowed within fair use. | Pro | Up to 100 reflections/month. | Not run with real entitlement. | Not tested |
+| 29 | Pro monthly report allowed. | Pro | Monthly report generation allowed for valid month. | Not run with real entitlement. | Not tested |
+| 30 | Pro advanced insights allowed. | Pro | Advanced AI cards render from reports. | Not run with real entitlement. | Not tested |
+| 31 | AI Chat function rejects over-quota free user. | Free | `QUOTA_EXHAUSTED`; no provider call. | Not run against deployed function. | Not tested |
+| 32 | Reflection function rejects over-quota free user. | Free | `QUOTA_EXHAUSTED`; no provider call. | Not run against deployed function. | Not tested |
+| 33 | Monthly report rejects free user. | Free | `QUOTA_EXHAUSTED`; no provider call. | Not run against deployed function. | Not tested |
+| 34 | Server does not trust client-sent `isPro`. | Free | Client cannot pass entitlement to AI functions. | Source implemented. | Not tested |
+| 35 | Missing/invalid auth token rejected. | Free | 401. | Existing auth path, not rerun. | Not tested |
+| 36 | Cross-user request rejected. | Mixed | RLS/JWT subject scope enforced. | Existing auth path, not rerun. | Not tested |
+| 37 | Quota race condition tested with rapid requests. | Free/Pro | Atomic RPC prevents over-limit increments. | Not load tested. | Not tested |
+| 38 | Free journaling works offline. | Free | Core journaling remains local-first. | Not run on device. | Not tested |
+| 39 | Paywall handles offline gracefully. | Free | Purchase unavailable/friendly error. | Not run on device. | Not tested |
+| 40 | AI action offline shows existing offline error. | Free/Pro | No server quota bypass. | Not run on device. | Not tested |
+| 41 | Cached Pro does not bypass server AI enforcement. | Pro offline | AI requires server access. | Source implemented. | Not tested |
+| 42 | Journal CRUD. | Any | No regression. | Static checks pending after implementation. | Not tested |
+| 43 | Manual tags. | Any | No regression. | Static checks pending after implementation. | Not tested |
+| 44 | AI-generated tags. | Any | Generated only with allowed reflection. | Static checks pending after implementation. | Not tested |
+| 45 | Dynamic Home prompts. | Any | Remain free. | Static checks pending after implementation. | Not tested |
+| 46 | AI Chat rendering. | Any | No regression. | Static checks pending after implementation. | Not tested |
+| 47 | Reports rendering. | Any | No regression. | Static checks pending after implementation. | Not tested |
+| 48 | App Lock. | Any | Paywall does not bypass lock. | Not run on device. | Not tested |
+| 49 | Account deletion. | Any | Still available and not subscription-gated. | Static source checked. | Not tested |
+| 50 | Screen transitions. | Any | No regression. | Not run on device. | Not tested |
diff --git a/docs/subscription-setup.md b/docs/subscription-setup.md
new file mode 100644
index 0000000..547ca2d
--- /dev/null
+++ b/docs/subscription-setup.md
@@ -0,0 +1,64 @@
+# Subscription Setup
+
+DearDiary Pro uses RevenueCat on the client and server-side Supabase Edge Function checks for AI-costing features.
+
+## Identifiers
+
+- Entitlement: `DearDiary Pro`
+- Offering: `default`
+- Expected monthly product: `deardiary_pro_monthly`
+- Expected yearly product: `deardiary_pro_yearly`
+
+Use the actual store product IDs if App Store Connect, Google Play Console, or RevenueCat are configured differently. Do not hardcode display prices in the app. The paywall renders `package.product.priceString` from RevenueCat packages.
+
+## RevenueCat Account Status
+
+Last checked with RevenueCat MCP on 2026-07-12.
+
+- Project: `DearDiary` (`proj11badf66`)
+- Current offering: `default` (`ofrng0ecaeb85d3`)
+- Monthly package: `$rc_monthly` (`pkge73858b426a`)
+- Yearly package: `$rc_annual` (`pkgef61fa3ca89`)
+- Test Store app: `Test Store` (`app4bec91040f`)
+- Android app: `DearDiary Android` (`app43d78856cd`) with package `com.aryan.deardiary`
+- Test Store monthly product: `deardiary_pro_monthly`
+- Test Store yearly product: `deardiary_pro_yearly`
+- Android monthly product record: `deardiary_pro_monthly:monthly`
+- Android yearly product record: `deardiary_pro_yearly:yearly`
+
+The app and Edge Functions intentionally use the active RevenueCat entitlement identifier `DearDiary Pro`. Do not rename it without updating `revenueCatEntitlementId` in the app and the shared Edge Function subscription helper.
+
+Google Play Store state is not fully synced yet because RevenueCat reports missing store credentials for the Play Store app. Add Google Play service credentials in RevenueCat, create matching Play subscriptions/base plans, and verify store state before production testing.
+
+## Environment
+
+Client-safe Expo public variables:
+
+```text
+EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY
+EXPO_PUBLIC_REVENUECAT_IOS_API_KEY
+```
+
+Server-only Supabase Edge Function secrets:
+
+```text
+REVENUECAT_SECRET_API_KEY
+SUPABASE_SERVICE_ROLE_KEY
+```
+
+Do not put RevenueCat secret keys, Supabase service-role keys, store credentials, or AI provider keys in Expo public variables.
+
+## Server Enforcement
+
+This implementation uses RevenueCat REST validation from AI Edge Functions, with `subscription_status` available as a future webhook mirror table. Edge Functions check:
+
+1. Authenticated Clerk user from the JWT subject.
+2. RevenueCat `DearDiary Pro` entitlement using the server-only RevenueCat secret, or an active mirrored `subscription_status` row.
+3. Monthly usage quota through `increment_ai_usage_if_allowed`.
+4. Provider call only after access is allowed.
+
+Usage period is UTC calendar month with `YYYY-MM` keys. Requests denied before the provider call are not counted. Requests that pass quota and reach the provider count even if the provider later fails.
+
+## Paywall Intent
+
+When a user hits a premium gate, the app opens `/paywall` with a `feature` parameter. After successful purchase or restore, the app returns to the previous screen and asks the user to tap the action again. It does not automatically trigger a new expensive AI request after purchase.
diff --git a/docs/subscription-store-setup.md b/docs/subscription-store-setup.md
new file mode 100644
index 0000000..944edff
--- /dev/null
+++ b/docs/subscription-store-setup.md
@@ -0,0 +1,82 @@
+# RevenueCat Setup
+
+- [ ] Project created
+- [ ] iOS app configured
+- [ ] Android app configured
+- [ ] Entitlement `DearDiary Pro` created
+- [ ] Offering `default` created
+- [ ] Monthly product attached
+- [ ] Yearly product attached
+- [ ] Products synced from stores
+- [ ] Sandbox testing configured
+- [ ] Webhooks configured if used
+- [ ] API keys added to EAS environments
+- [ ] `REVENUECAT_SECRET_API_KEY` added to Supabase Edge Function secrets
+
+# Google Play
+
+- [ ] Subscription/base plan created
+- [ ] Monthly plan price set
+- [ ] Yearly plan price set
+- [ ] India price set to Rs 349/month
+- [ ] India yearly price set
+- [ ] Global monthly price at least $4.99 equivalent
+- [ ] Test license accounts configured
+- [ ] App uploaded to internal testing track if required
+
+## Google Play Step-by-Step
+
+Use the Play Console app whose package name is `com.aryan.deardiary`.
+
+1. Open Play Console and create or select the DearDiary app.
+2. Complete the app setup items required before monetization, including the payments profile/merchant setup if Play asks for it.
+3. Upload a signed Android build to an internal testing track. Billing products often cannot be tested reliably until Google Play has a signed artifact for the same package name.
+4. Go to **Monetize with Play > Products > Subscriptions**.
+5. Create the monthly subscription:
+ - Product ID: `deardiary_pro_monthly`
+ - Name: `DearDiary Pro Monthly`
+ - Description/internal note: `Monthly DearDiary Pro subscription`
+6. Add an auto-renewing base plan to the monthly subscription:
+ - Base plan ID: `monthly`
+ - Billing period: Monthly
+ - Country/region availability: include India and all launch countries
+ - India price: Rs 349/month
+ - Global fallback: at least USD 4.99 equivalent
+ - Activate the base plan
+7. Create the yearly subscription:
+ - Product ID: `deardiary_pro_yearly`
+ - Name: `DearDiary Pro Yearly`
+ - Description/internal note: `Yearly DearDiary Pro subscription`
+8. Add an auto-renewing base plan to the yearly subscription:
+ - Base plan ID: `yearly`
+ - Billing period: Yearly
+ - Country/region availability: include India and all launch countries
+ - India price: Rs 2,999/year recommended
+ - Global fallback: USD 39.99 or USD 49.99 equivalent
+ - Activate the base plan
+9. In Google Cloud Console, enable the Google Play Android Developer API and Google Play Developer Reporting API for the linked project.
+10. Create a service account for RevenueCat and download a JSON key.
+11. In Play Console **Users and permissions**, invite the service account email and grant the app permissions RevenueCat needs:
+ - View app information and download bulk reports
+ - View financial data, orders, and cancellation survey response
+ - Manage orders and subscriptions
+12. In RevenueCat, open project `DearDiary` > Android app `DearDiary Android` and upload the service account JSON under Google Play service credentials.
+13. Wait for RevenueCat credential validation. RevenueCat notes this can take up to 36 hours.
+14. Confirm RevenueCat product records match:
+ - Monthly Android product: `deardiary_pro_monthly:monthly`
+ - Yearly Android product: `deardiary_pro_yearly:yearly`
+15. Confirm RevenueCat offering `default` has:
+ - `$rc_monthly` attached to the monthly Android product
+ - `$rc_annual` attached to the yearly Android product
+16. Confirm RevenueCat entitlement `DearDiary Pro` has both Android products attached.
+17. Add Google Play license testers in Play Console.
+18. Build and install an Android Preview/Internal APK/AAB from the internal track, then test purchases outside Expo Go.
+
+# App Store
+
+- [ ] Auto-renewable subscription group created
+- [ ] Monthly product created
+- [ ] Yearly product created
+- [ ] India pricing set
+- [ ] Global pricing set
+- [ ] Sandbox testers configured
diff --git a/hooks/useAIInsightReport.ts b/hooks/useAIInsightReport.ts
index d59d921..04ef250 100644
--- a/hooks/useAIInsightReport.ts
+++ b/hooks/useAIInsightReport.ts
@@ -34,10 +34,10 @@ export type UseAIInsightReportResult = {
legacyReportAvailable: boolean;
availableEntryCount: number;
error: string | null;
- generate: () => Promise;
- regenerate: () => Promise;
+ generate: () => Promise;
+ regenerate: () => Promise;
refresh: () => Promise;
- retry: () => Promise;
+ retry: () => Promise;
};
type UseAIInsightReportOptions = {
@@ -286,17 +286,17 @@ export function useAIInsightReport(
const requestGeneration = useCallback(
async (regenerate: boolean) => {
if (!enabled) {
- return;
+ return false;
}
if (cacheHydrationError) {
setError(cacheHydrationError.userMessage);
- return;
+ return false;
}
if (moodLogsHydrationError) {
setError(moodLogsHydrationError.userMessage);
- return;
+ return false;
}
if (
@@ -305,17 +305,17 @@ export function useAIInsightReport(
!moodLogsHaveHydrated ||
requestInFlightRef.current
) {
- return;
+ return false;
}
if (!userId) {
setError("Please sign in again before generating a reflection report.");
- return;
+ return false;
}
if (connectivity.status === "offline") {
setError("Connect to the internet to generate a reflection report.");
- return;
+ return false;
}
const requestVersion = requestContextVersionRef.current;
@@ -332,14 +332,14 @@ export function useAIInsightReport(
});
if (!isCurrentRequestContext(requestVersion)) {
- return;
+ return false;
}
if (!sourcesAreSynced) {
setError(
"Please try again after your latest entries and mood check-ins finish syncing.",
);
- return;
+ return false;
}
const generatedReport = await generateAIInsightReport({
@@ -348,24 +348,26 @@ export function useAIInsightReport(
});
if (!isCurrentRequestContext(requestVersion)) {
- return;
+ return false;
}
if (generatedReport.userId !== userId) {
setError("Reflection report could not be loaded for this account.");
- return;
+ return false;
}
setReport(generatedReport);
setLegacyReportAvailable(false);
setCachedReport(userId, cacheKey, generatedReport);
lastFailedOperationRef.current = null;
+ return true;
} catch (generationError) {
if (!isCurrentRequestContext(requestVersion)) {
- return;
+ return false;
}
setError(getErrorMessage(generationError));
+ return false;
} finally {
if (isCurrentRequestContext(requestVersion)) {
requestInFlightRef.current = false;
diff --git a/hooks/useAppSignOut.ts b/hooks/useAppSignOut.ts
new file mode 100644
index 0000000..18f6220
--- /dev/null
+++ b/hooks/useAppSignOut.ts
@@ -0,0 +1,38 @@
+import { useClerk } from "@clerk/expo";
+import { useCallback } from "react";
+
+import {
+ getSupabaseAccessTokenProvider,
+ setSupabaseAccessTokenProvider,
+} from "@/lib/supabase";
+import { useJournalStore } from "@/store/journal-store";
+import { useSyncStore } from "@/store/useSyncStore";
+
+export function useAppSignOut() {
+ const { signOut } = useClerk();
+ const setActiveUserId = useJournalStore((state) => state.setActiveUserId);
+
+ return useCallback(
+ async (currentUserId?: string | null) => {
+ const previousActiveUserId = useJournalStore.getState().activeUserId;
+ const signingOutUserId = currentUserId ?? previousActiveUserId;
+ const previousAccessTokenProvider = getSupabaseAccessTokenProvider();
+
+ try {
+ await signOut();
+ setActiveUserId(null);
+
+ if (signingOutUserId) {
+ useSyncStore.getState().clearSyncStateForUser(signingOutUserId);
+ }
+
+ setSupabaseAccessTokenProvider(null);
+ } catch (error) {
+ setActiveUserId(previousActiveUserId);
+ setSupabaseAccessTokenProvider(previousAccessTokenProvider);
+ throw error;
+ }
+ },
+ [setActiveUserId, signOut],
+ );
+}
diff --git a/hooks/useEntryReflection.ts b/hooks/useEntryReflection.ts
index 97a06cf..63f2690 100644
--- a/hooks/useEntryReflection.ts
+++ b/hooks/useEntryReflection.ts
@@ -25,13 +25,13 @@ type UseEntryReflectionParams = {
type UseEntryReflectionResult = {
error: string | null;
- generate: () => Promise;
+ generate: () => Promise;
isGenerating: boolean;
isLoading: boolean;
isStale: boolean;
reflection: EntryAIReflection | null;
refresh: () => Promise;
- regenerate: () => Promise;
+ regenerate: () => Promise;
};
export function useEntryReflection({
@@ -140,7 +140,7 @@ export function useEntryReflection({
const runGeneration = useCallback(
async (regenerate: boolean) => {
if (!canUseReflection || !entryId || !userId || isGenerating) {
- return;
+ return false;
}
if (cacheHydrationError) {
@@ -152,12 +152,12 @@ export function useEntryReflection({
cacheHydrationError;
setError(currentError.userMessage);
- return;
+ return false;
}
}
if (!cacheHasHydrated) {
- return;
+ return false;
}
const requestGeneration = requestGenerationRef.current;
@@ -171,7 +171,7 @@ export function useEntryReflection({
});
if (requestGenerationRef.current !== requestGeneration) {
- return;
+ return false;
}
if (generatedReflection.userId === userId) {
@@ -183,13 +183,18 @@ export function useEntryReflection({
upsertReflection(reflectionForLocalCache);
setRemoteReflection(reflectionForLocalCache);
+ return true;
}
+
+ setError("AI reflection could not be loaded for this account.");
+ return false;
} catch (generationError) {
if (requestGenerationRef.current !== requestGeneration) {
- return;
+ return false;
}
setError(getReflectionErrorMessage(generationError));
+ return false;
} finally {
if (requestGenerationRef.current === requestGeneration) {
setIsGenerating(false);
diff --git a/hooks/useFeatureAccess.ts b/hooks/useFeatureAccess.ts
new file mode 100644
index 0000000..cee9b5e
--- /dev/null
+++ b/hooks/useFeatureAccess.ts
@@ -0,0 +1,27 @@
+import { useMemo } from "react";
+
+import { getFeatureAccess } from "@/lib/subscription/featureGates";
+import type { PremiumFeature } from "@/lib/subscription/constants";
+import { useSubscription } from "@/hooks/useSubscription";
+import { useAIUsageStore } from "@/store/useAIUsageStore";
+
+export function useFeatureAccess(
+ feature: PremiumFeature,
+ userId: string | null | undefined,
+) {
+ const { isConfigured, isPro } = useSubscription();
+ const monthlyUsage = useAIUsageStore((state) =>
+ state.getMonthlyUsage(userId, feature),
+ );
+
+ return useMemo(
+ () =>
+ getFeatureAccess({
+ feature,
+ isPro,
+ monthlyUsage,
+ subscriptionAvailable: isConfigured,
+ }),
+ [feature, isConfigured, isPro, monthlyUsage],
+ );
+}
diff --git a/hooks/useReportAccessActions.ts b/hooks/useReportAccessActions.ts
new file mode 100644
index 0000000..4febd1a
--- /dev/null
+++ b/hooks/useReportAccessActions.ts
@@ -0,0 +1,125 @@
+import { type Href, useRouter } from "expo-router";
+import { useCallback, useMemo } from "react";
+
+import { useAppDialog } from "@/hooks/useAppDialog";
+import type { UseAIInsightReportResult } from "@/hooks/useAIInsightReport";
+import type {
+ FeatureAccessResult,
+ PremiumFeature,
+} from "@/lib/subscription/constants";
+import { useAIUsageStore } from "@/store/useAIUsageStore";
+
+const reportFairUseMessage =
+ "You've reached this month's DearDiary Pro fair-use limit for reflection reports. Please try again next month.";
+
+export function useReportAccessActions({
+ feature,
+ reportAccess,
+ reportState,
+ userId,
+}: {
+ feature: PremiumFeature;
+ reportAccess: FeatureAccessResult;
+ reportState: UseAIInsightReportResult;
+ userId: string | null | undefined;
+}) {
+ const router = useRouter();
+ const { showDialog } = useAppDialog();
+ const fairUseLimitMessage =
+ reportAccess.reason === "Pro_fair_use_exhausted"
+ ? reportFairUseMessage
+ : null;
+
+ const showFairUseDialog = useCallback(() => {
+ showDialog({
+ confirmText: "OK",
+ message: reportFairUseMessage,
+ title: "Monthly report limit reached",
+ });
+ }, [showDialog]);
+
+ const canUseReportAccess = useCallback(() => {
+ if (reportAccess.allowed) {
+ return true;
+ }
+
+ if (reportAccess.reason === "Pro_fair_use_exhausted") {
+ showFairUseDialog();
+ return false;
+ }
+
+ router.push({
+ pathname: "/paywall",
+ params: { feature },
+ } as unknown as Href);
+ return false;
+ }, [
+ feature,
+ reportAccess.allowed,
+ reportAccess.reason,
+ router,
+ showFairUseDialog,
+ ]);
+
+ const recordUsage = useCallback(() => {
+ if (userId) {
+ useAIUsageStore.getState().incrementMonthlyUsage(userId, feature);
+ }
+ }, [feature, userId]);
+
+ const handleConfirmedRegenerate = useCallback(async () => {
+ const regenerated = await reportState.regenerate();
+
+ if (regenerated) {
+ recordUsage();
+ }
+ }, [recordUsage, reportState]);
+
+ const handleGenerate = useCallback(async () => {
+ if (!canUseReportAccess()) {
+ return;
+ }
+
+ const generated = await reportState.generate();
+
+ if (generated) {
+ recordUsage();
+ }
+ }, [canUseReportAccess, recordUsage, reportState]);
+
+ const handleRegenerate = useCallback(() => {
+ if (!canUseReportAccess()) {
+ return;
+ }
+
+ showDialog({
+ cancelText: "Keep Current",
+ confirmText: "Regenerate",
+ icon: "✦",
+ message:
+ "DearDiary will analyze this period again and replace the current visual reflection.",
+ onConfirm: () => {
+ void handleConfirmedRegenerate();
+ },
+ showCancel: true,
+ title: "Regenerate reflection?",
+ });
+ }, [canUseReportAccess, handleConfirmedRegenerate, showDialog]);
+
+ return useMemo(
+ () => ({
+ canUseReportAccess,
+ fairUseLimitMessage,
+ handleConfirmedRegenerate,
+ handleGenerate,
+ handleRegenerate,
+ }),
+ [
+ canUseReportAccess,
+ fairUseLimitMessage,
+ handleConfirmedRegenerate,
+ handleGenerate,
+ handleRegenerate,
+ ],
+ );
+}
diff --git a/hooks/useSubscription.ts b/hooks/useSubscription.ts
new file mode 100644
index 0000000..449d0c6
--- /dev/null
+++ b/hooks/useSubscription.ts
@@ -0,0 +1,41 @@
+import { createContext, useContext } from "react";
+
+import type {
+ CustomerInfo,
+ PurchasesOfferings,
+ PurchasesPackage,
+} from "react-native-purchases";
+
+export type SubscriptionState = {
+ customerInfo: CustomerInfo | null;
+ error: string | null;
+ isConfigured: boolean;
+ isLoading: boolean;
+ isPro: boolean;
+ offerings: PurchasesOfferings | null;
+ purchasePackage: (selectedPackage: PurchasesPackage) => Promise;
+ refresh: () => Promise;
+ restorePurchases: () => Promise;
+};
+
+export const SubscriptionContext = createContext(
+ null,
+);
+
+export function useSubscription() {
+ const value = useContext(SubscriptionContext);
+
+ if (!value) {
+ throw new Error("useSubscription must be used within SubscriptionProvider.");
+ }
+
+ return value;
+}
+
+export function useIsPro() {
+ return useSubscription().isPro;
+}
+
+export function useCustomerInfo() {
+ return useSubscription().customerInfo;
+}
diff --git a/lib/ai/dailyReflectionPromptService.ts b/lib/ai/dailyReflectionPromptService.ts
index d3d7a00..27c5c1b 100644
--- a/lib/ai/dailyReflectionPromptService.ts
+++ b/lib/ai/dailyReflectionPromptService.ts
@@ -75,10 +75,10 @@ async function requestDailyReflectionPromptBundle(params: {
if (error) {
if (__DEV__) {
- console.warn("Daily reflection prompt generation failed", {
- name: error.name,
- requestFailed: true,
- });
+ console.warn(
+ "Daily reflection prompt generation failed",
+ await getFunctionErrorDetails(error),
+ );
}
throw getUserFacingFunctionError(error);
@@ -154,6 +154,66 @@ function getUserFacingFunctionError(error: unknown) {
);
}
+async function getFunctionErrorDetails(error: unknown) {
+ if (error instanceof FunctionsHttpError) {
+ const response = error.context;
+ const body = await readFunctionErrorBody(response);
+
+ return {
+ body,
+ name: error.name,
+ requestFailed: true,
+ status: response instanceof Response ? response.status : undefined,
+ };
+ }
+
+ if (error instanceof FunctionsRelayError) {
+ const response = error.context;
+
+ return {
+ name: error.name,
+ requestFailed: true,
+ status: response instanceof Response ? response.status : undefined,
+ };
+ }
+
+ if (error instanceof FunctionsFetchError) {
+ return {
+ message: error.message,
+ name: error.name,
+ requestFailed: true,
+ };
+ }
+
+ return {
+ message: error instanceof Error ? error.message : "Unknown error",
+ name: error instanceof Error ? error.name : "UnknownError",
+ requestFailed: true,
+ };
+}
+
+async function readFunctionErrorBody(response: unknown) {
+ if (!(response instanceof Response)) {
+ return null;
+ }
+
+ try {
+ const body: unknown = await response.clone().json();
+
+ if (!isRecord(body)) {
+ return null;
+ }
+
+ return {
+ code: typeof body.code === "string" ? body.code : undefined,
+ requestId:
+ typeof body.requestId === "string" ? body.requestId : undefined,
+ };
+ } catch {
+ return null;
+ }
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
diff --git a/lib/ai/entryReflectionService.ts b/lib/ai/entryReflectionService.ts
index 48c42b2..d94fe4f 100644
--- a/lib/ai/entryReflectionService.ts
+++ b/lib/ai/entryReflectionService.ts
@@ -85,7 +85,7 @@ export const generateEntryReflection = async (params: {
);
}
- throw getUserFacingFunctionError(error);
+ throw await getUserFacingFunctionError(error);
}
if (!isGenerateEntryReflectionResponse(data)) {
@@ -256,7 +256,7 @@ function getEntryReflectionClient() {
}
}
-function getUserFacingFunctionError(error: unknown) {
+async function getUserFacingFunctionError(error: unknown) {
if (error instanceof FunctionsFetchError) {
return new EntryReflectionServiceError(
"AI reflection needs an internet connection. Your journal entry is still saved safely.",
@@ -265,8 +265,10 @@ function getUserFacingFunctionError(error: unknown) {
}
if (error instanceof FunctionsHttpError) {
+ const body = await readFunctionErrorBody(error.context);
+
return new EntryReflectionServiceError(
- getHttpErrorMessage(error.context),
+ getHttpErrorMessage(error.context, body),
"function_http_error",
);
}
@@ -284,7 +286,10 @@ function getUserFacingFunctionError(error: unknown) {
);
}
-function getHttpErrorMessage(response: unknown) {
+function getHttpErrorMessage(
+ response: unknown,
+ body?: FunctionErrorBody | null,
+) {
if (!(response instanceof Response)) {
return "DearDiary AI is unavailable right now.";
}
@@ -297,8 +302,16 @@ function getHttpErrorMessage(response: unknown) {
return "This journal entry could not be found.";
}
+ if (body?.code === "QUOTA_EXHAUSTED") {
+ return "You've used your free AI reflections for this month. Upgrade to DearDiary Pro for more reflections, reports, and insights.";
+ }
+
+ if (body?.code === "PRO_FAIR_USE_EXHAUSTED") {
+ return "You've reached this month's DearDiary Pro fair-use limit for AI reflections. Please try again next month.";
+ }
+
if (response.status === 503) {
- return "AI reflections are still being set up. Please try again after the database migration is applied.";
+ return "AI reflections are still being set up. Please try again after subscription usage tracking is configured.";
}
if (response.status === 502) {
@@ -308,6 +321,11 @@ function getHttpErrorMessage(response: unknown) {
return "DearDiary AI is unavailable right now.";
}
+type FunctionErrorBody = {
+ code?: string;
+ requestId?: string;
+};
+
async function getFunctionErrorDetails(error: unknown) {
if (error instanceof FunctionsHttpError) {
const response = error.context;
diff --git a/lib/ai/remoteJournalAssistant.ts b/lib/ai/remoteJournalAssistant.ts
index aae3a28..85f3633 100644
--- a/lib/ai/remoteJournalAssistant.ts
+++ b/lib/ai/remoteJournalAssistant.ts
@@ -73,10 +73,7 @@ export const generateRemoteJournalResponse = async (params: {
);
}
- throw new RemoteJournalAssistantError(
- "DearDiary AI is unavailable.",
- "remote_unavailable",
- );
+ throw await getUserFacingFunctionError(error);
}
if (!isRemoteJournalResponse(data)) {
@@ -116,6 +113,38 @@ function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
+async function getUserFacingFunctionError(error: unknown) {
+ if (error instanceof FunctionsHttpError) {
+ const body = await readFunctionErrorBody(error.context);
+
+ if (body?.code === "QUOTA_EXHAUSTED") {
+ return new RemoteJournalAssistantError(
+ "You've used your free AI Chat messages for this month. Upgrade to DearDiary Pro for more AI reflection support.",
+ "quota_exhausted",
+ );
+ }
+
+ if (body?.code === "PRO_FAIR_USE_EXHAUSTED") {
+ return new RemoteJournalAssistantError(
+ "You've reached this month's DearDiary Pro fair-use limit for AI Chat. Please try again next month.",
+ "pro_fair_use_exhausted",
+ );
+ }
+ }
+
+ if (error instanceof FunctionsFetchError) {
+ return new RemoteJournalAssistantError(
+ "AI Chat needs an internet connection.",
+ "network_unavailable",
+ );
+ }
+
+ return new RemoteJournalAssistantError(
+ "DearDiary AI is unavailable.",
+ "remote_unavailable",
+ );
+}
+
async function getFunctionErrorDetails(error: unknown) {
if (error instanceof FunctionsHttpError) {
const response = error.context;
diff --git a/lib/environment.ts b/lib/environment.ts
index eae99c0..585ff8d 100644
--- a/lib/environment.ts
+++ b/lib/environment.ts
@@ -4,6 +4,8 @@ export type PublicEnvironment = {
accountDeletionUrl: string | null;
appEnvironment: AppEnvironment;
clerkPublishableKey: string;
+ revenueCatAndroidApiKey: string | null;
+ revenueCatIosApiKey: string | null;
supabasePublicKey: string;
supabaseUrl: string;
};
@@ -12,6 +14,8 @@ export type PublicEnvironmentInput = {
accountDeletionUrl?: string;
appEnvironment?: string;
clerkPublishableKey?: string;
+ revenueCatAndroidApiKey?: string;
+ revenueCatIosApiKey?: string;
supabasePublicKey?: string;
supabaseUrl?: string;
};
@@ -30,6 +34,9 @@ export const publicEnvironmentResult = validatePublicEnvironment({
accountDeletionUrl: process.env.EXPO_PUBLIC_ACCOUNT_DELETION_URL,
appEnvironment: process.env.EXPO_PUBLIC_APP_ENV,
clerkPublishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY,
+ revenueCatAndroidApiKey:
+ process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY,
+ revenueCatIosApiKey: process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY,
supabasePublicKey: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY,
supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL,
});
@@ -47,6 +54,8 @@ export function validatePublicEnvironment(
const rawAccountDeletionUrl = input.accountDeletionUrl?.trim();
const rawAppEnvironment = input.appEnvironment?.trim();
const rawClerkPublishableKey = input.clerkPublishableKey?.trim();
+ const rawRevenueCatAndroidApiKey = input.revenueCatAndroidApiKey?.trim();
+ const rawRevenueCatIosApiKey = input.revenueCatIosApiKey?.trim();
const rawSupabasePublicKey = input.supabasePublicKey?.trim();
const rawSupabaseUrl = input.supabaseUrl?.trim();
const appEnvironment = getAppEnvironment(rawAppEnvironment);
@@ -110,6 +119,8 @@ export function validatePublicEnvironment(
accountDeletionUrl: rawAccountDeletionUrl || null,
appEnvironment,
clerkPublishableKey: rawClerkPublishableKey,
+ revenueCatAndroidApiKey: rawRevenueCatAndroidApiKey || null,
+ revenueCatIosApiKey: rawRevenueCatIosApiKey || null,
supabasePublicKey: rawSupabasePublicKey,
supabaseUrl: rawSupabaseUrl,
},
diff --git a/lib/insights/aiInsightReportService.ts b/lib/insights/aiInsightReportService.ts
index f3c5279..c93e7d6 100644
--- a/lib/insights/aiInsightReportService.ts
+++ b/lib/insights/aiInsightReportService.ts
@@ -234,12 +234,22 @@ function getHttpErrorMessage(
return "DearDiary created the reflection, but could not save the report. Please try again.";
}
+ if (body?.code === "QUOTA_EXHAUSTED") {
+ return periodType === "weekly"
+ ? "You've used your free weekly AI report for this month. Upgrade to DearDiary Pro for more reports and insights."
+ : "Monthly AI reports are included with DearDiary Pro.";
+ }
+
+ if (body?.code === "PRO_FAIR_USE_EXHAUSTED") {
+ return "You've reached this month's DearDiary Pro fair-use limit for reflection reports. Please try again next month.";
+ }
+
if (response.status === 401) {
return "Please sign in again before generating a reflection report.";
}
if (response.status === 404 || response.status === 503) {
- return "Visual reflection reports are still being set up. Apply the database migration and deploy the report function, then try again.";
+ return "Visual reflection reports are still being set up. Apply the subscription usage migration and deploy the report function, then try again.";
}
if (response.status === 422) {
diff --git a/lib/subscription/constants.ts b/lib/subscription/constants.ts
new file mode 100644
index 0000000..f762a8c
--- /dev/null
+++ b/lib/subscription/constants.ts
@@ -0,0 +1,79 @@
+export const revenueCatEntitlementId = "DearDiary Pro";
+export const revenueCatOfferingId = "default";
+
+export const expectedRevenueCatProductIds = {
+ monthly: "deardiary_pro_monthly",
+ yearly: "deardiary_pro_yearly",
+} as const;
+
+export type PremiumFeature =
+ | "ai_chat"
+ | "entry_reflection"
+ | "weekly_report"
+ | "monthly_report"
+ | "advanced_insights"
+ | "long_term_summary"
+ | "personalized_suggestions";
+
+export type FeatureAccessReason =
+ | "allowed_free"
+ | "allowed_Pro"
+ | "free_quota_remaining"
+ | "free_quota_exhausted"
+ | "Pro_fair_use_exhausted"
+ | "subscription_unavailable";
+
+export type FeatureAccessResult = {
+ allowed: boolean;
+ limit?: number;
+ reason: FeatureAccessReason;
+ remaining?: number;
+};
+
+type FeatureLimit = {
+ freeMonthlyLimit: number | null;
+ proMonthlyLimit: number | null;
+ proOnly: boolean;
+};
+
+export const premiumFeatureLimits: Record = {
+ advanced_insights: {
+ freeMonthlyLimit: 0,
+ proMonthlyLimit: null,
+ proOnly: true,
+ },
+ ai_chat: {
+ freeMonthlyLimit: 10,
+ proMonthlyLimit: 300,
+ proOnly: false,
+ },
+ entry_reflection: {
+ freeMonthlyLimit: 3,
+ proMonthlyLimit: 100,
+ proOnly: false,
+ },
+ long_term_summary: {
+ freeMonthlyLimit: 0,
+ proMonthlyLimit: null,
+ proOnly: true,
+ },
+ monthly_report: {
+ freeMonthlyLimit: 0,
+ proMonthlyLimit: null,
+ proOnly: true,
+ },
+ personalized_suggestions: {
+ freeMonthlyLimit: 0,
+ proMonthlyLimit: null,
+ proOnly: true,
+ },
+ weekly_report: {
+ freeMonthlyLimit: 1,
+ proMonthlyLimit: null,
+ proOnly: false,
+ },
+};
+
+export function getCurrentUTCMonthKey(date = new Date()) {
+ return date.toISOString().slice(0, 7);
+}
diff --git a/lib/subscription/featureGates.ts b/lib/subscription/featureGates.ts
new file mode 100644
index 0000000..92aacdf
--- /dev/null
+++ b/lib/subscription/featureGates.ts
@@ -0,0 +1,46 @@
+import {
+ premiumFeatureLimits,
+ type FeatureAccessResult,
+ type PremiumFeature,
+} from "@/lib/subscription/constants";
+
+export function getFeatureAccess(params: {
+ feature: PremiumFeature;
+ isPro: boolean;
+ monthlyUsage: number;
+ subscriptionAvailable: boolean;
+}): FeatureAccessResult {
+ const limit = premiumFeatureLimits[params.feature];
+
+ if (params.isPro) {
+ if (limit.proMonthlyLimit === null) {
+ return { allowed: true, reason: "allowed_Pro" };
+ }
+
+ const remaining = Math.max(0, limit.proMonthlyLimit - params.monthlyUsage);
+
+ return {
+ allowed: remaining > 0,
+ limit: limit.proMonthlyLimit,
+ reason: remaining > 0 ? "allowed_Pro" : "Pro_fair_use_exhausted",
+ remaining,
+ };
+ }
+
+ if (limit.proOnly) {
+ return { allowed: false, reason: "subscription_unavailable" };
+ }
+
+ if (limit.freeMonthlyLimit === null) {
+ return { allowed: true, reason: "allowed_free" };
+ }
+
+ const remaining = Math.max(0, limit.freeMonthlyLimit - params.monthlyUsage);
+
+ return {
+ allowed: remaining > 0,
+ limit: limit.freeMonthlyLimit,
+ reason: remaining > 0 ? "free_quota_remaining" : "free_quota_exhausted",
+ remaining,
+ };
+}
diff --git a/lib/subscription/revenueCat.ts b/lib/subscription/revenueCat.ts
new file mode 100644
index 0000000..2d4d520
--- /dev/null
+++ b/lib/subscription/revenueCat.ts
@@ -0,0 +1,91 @@
+import Purchases, {
+ LOG_LEVEL,
+ PURCHASES_ERROR_CODE,
+ type CustomerInfo,
+ type PurchasesError,
+} from "react-native-purchases";
+
+import { getPublicEnvironment } from "@/lib/environment";
+import { revenueCatEntitlementId } from "@/lib/subscription/constants";
+
+export type RevenueCatPlatform = "android" | "ios";
+
+export function getRevenueCatPlatform(): RevenueCatPlatform | null {
+ if (process.env.EXPO_OS === "android" || process.env.EXPO_OS === "ios") {
+ return process.env.EXPO_OS;
+ }
+
+ return null;
+}
+
+export function getRevenueCatApiKey() {
+ const environment = getPublicEnvironment();
+ const platform = getRevenueCatPlatform();
+
+ if (!environment || !platform) {
+ return null;
+ }
+
+ return platform === "ios"
+ ? environment.revenueCatIosApiKey
+ : environment.revenueCatAndroidApiKey;
+}
+
+export function hasProEntitlement(customerInfo: CustomerInfo | null) {
+ return Boolean(customerInfo?.entitlements.active[revenueCatEntitlementId]);
+}
+
+export function isRevenueCatPurchaseCancelled(error: unknown) {
+ return (
+ isPurchasesError(error) &&
+ error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR
+ );
+}
+
+export function getFriendlyRevenueCatError(error: unknown) {
+ if (!isPurchasesError(error)) {
+ return "We could not complete the purchase. Please try again.";
+ }
+
+ if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
+ return "Purchase was cancelled.";
+ }
+
+ if (error.code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
+ return "Your purchase is pending. Please check your store account.";
+ }
+
+ if (error.code === PURCHASES_ERROR_CODE.NETWORK_ERROR) {
+ return "Please check your connection and try again.";
+ }
+
+ if (
+ error.code === PURCHASES_ERROR_CODE.PRODUCT_NOT_AVAILABLE_FOR_PURCHASE_ERROR
+ ) {
+ return "This subscription is not available right now.";
+ }
+
+ if (error.code === PURCHASES_ERROR_CODE.PURCHASE_NOT_ALLOWED_ERROR) {
+ return "Purchases are not available for this store account.";
+ }
+
+ if (error.code === PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
+ return "This subscription is already active on your store account.";
+ }
+
+ return "We could not complete the purchase. Please try again.";
+}
+
+export function configureRevenueCat(apiKey: string, appUserID?: string) {
+ Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.INFO);
+ Purchases.configure(appUserID ? { apiKey, appUserID } : { apiKey });
+}
+
+function isPurchasesError(error: unknown): error is PurchasesError {
+ return (
+ typeof error === "object" &&
+ error !== null &&
+ "code" in error &&
+ typeof (error as { code: unknown }).code === "string"
+ );
+}
diff --git a/package-lock.json b/package-lock.json
index 70b9379..0e31a12 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"expo-auth-session": "~7.0.11",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
+ "expo-dev-client": "~6.0.21",
"expo-file-system": "~19.0.23",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
@@ -47,6 +48,7 @@
"react-native": "0.81.5",
"react-native-css": "^3.0.7",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-purchases": "10.4.2",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screen-transitions": "3.8.0",
@@ -4737,6 +4739,27 @@
"nanoid": "^3.3.11"
}
},
+ "node_modules/@revenuecat/purchases-js": {
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.47.0.tgz",
+ "integrity": "sha512-bytRHMnz4LKSeZLNE4crLo6FjbS2f80iJO+P4ZJ6U+qMJNMtWTkURVufCM3tHszVizhLtW8JYe8TdbLNADhiEQ==",
+ "license": "MIT"
+ },
+ "node_modules/@revenuecat/purchases-js-hybrid-mappings": {
+ "version": "18.19.0",
+ "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-18.19.0.tgz",
+ "integrity": "sha512-Oa9wKcOeax/ZDpF7l5mGZ9OWO0mVru+64lyg3bL45mL/cPqvam33CEY/JU7EDYKzj6xmVlNv5Xbe+hwtVLOO5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@revenuecat/purchases-js": "1.47.0"
+ }
+ },
+ "node_modules/@revenuecat/purchases-typescript-internal": {
+ "version": "18.19.0",
+ "resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-18.19.0.tgz",
+ "integrity": "sha512-XKOJ2CS+hYXnv0EUoQ7hV3AvHBcx0HqkLpnqYVMaAQyZ/BT/+CmUexVbXSfRAXGPBw0LTqR2gBQ4JrZEsYPxpw==",
+ "license": "MIT"
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -12144,6 +12167,79 @@
"expo": "*"
}
},
+ "node_modules/expo-dev-client": {
+ "version": "6.0.21",
+ "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.21.tgz",
+ "integrity": "sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-dev-launcher": "6.0.21",
+ "expo-dev-menu": "7.0.19",
+ "expo-dev-menu-interface": "2.0.0",
+ "expo-manifests": "~1.0.11",
+ "expo-updates-interface": "~2.0.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-dev-launcher": {
+ "version": "6.0.21",
+ "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz",
+ "integrity": "sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.11.0",
+ "expo-dev-menu": "7.0.19",
+ "expo-manifests": "~1.0.11"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-dev-launcher/node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/expo-dev-launcher/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/expo-dev-menu": {
+ "version": "7.0.19",
+ "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz",
+ "integrity": "sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-dev-menu-interface": "2.0.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-dev-menu-interface": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz",
+ "integrity": "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-file-system": {
"version": "19.0.23",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz",
@@ -12194,6 +12290,12 @@
}
}
},
+ "node_modules/expo-json-utils": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
+ "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
+ "license": "MIT"
+ },
"node_modules/expo-linear-gradient": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
@@ -12231,6 +12333,110 @@
"expo": "*"
}
},
+ "node_modules/expo-manifests": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz",
+ "integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/config": "~12.0.13",
+ "expo-json-utils": "~0.15.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-manifests/node_modules/@babel/code-frame": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
+ "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/highlight": "^7.10.4"
+ }
+ },
+ "node_modules/expo-manifests/node_modules/@expo/config": {
+ "version": "12.0.13",
+ "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz",
+ "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "~7.10.4",
+ "@expo/config-plugins": "~54.0.4",
+ "@expo/config-types": "^54.0.10",
+ "@expo/json-file": "^10.0.8",
+ "deepmerge": "^4.3.1",
+ "getenv": "^2.0.0",
+ "glob": "^13.0.0",
+ "require-from-string": "^2.0.2",
+ "resolve-from": "^5.0.0",
+ "resolve-workspace-root": "^2.0.0",
+ "semver": "^7.6.0",
+ "slugify": "^1.3.4",
+ "sucrase": "~3.35.1"
+ }
+ },
+ "node_modules/expo-manifests/node_modules/@expo/config-plugins": {
+ "version": "54.0.4",
+ "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz",
+ "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/config-types": "^54.0.10",
+ "@expo/json-file": "~10.0.8",
+ "@expo/plist": "^0.4.8",
+ "@expo/sdk-runtime-versions": "^1.0.0",
+ "chalk": "^4.1.2",
+ "debug": "^4.3.5",
+ "getenv": "^2.0.0",
+ "glob": "^13.0.0",
+ "resolve-from": "^5.0.0",
+ "semver": "^7.5.4",
+ "slash": "^3.0.0",
+ "slugify": "^1.6.6",
+ "xcode": "^3.0.1",
+ "xml2js": "0.6.0"
+ }
+ },
+ "node_modules/expo-manifests/node_modules/@expo/config-types": {
+ "version": "54.0.10",
+ "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz",
+ "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==",
+ "license": "MIT"
+ },
+ "node_modules/expo-manifests/node_modules/@expo/json-file": {
+ "version": "10.0.16",
+ "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz",
+ "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "~7.10.4",
+ "json5": "^2.2.3"
+ }
+ },
+ "node_modules/expo-manifests/node_modules/@expo/plist": {
+ "version": "0.4.9",
+ "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz",
+ "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.8",
+ "base64-js": "^1.2.3",
+ "xmlbuilder": "^15.1.1"
+ }
+ },
+ "node_modules/expo-manifests/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/expo-modules-autolinking": {
"version": "3.0.26",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
@@ -12618,6 +12824,15 @@
}
}
},
+ "node_modules/expo-updates-interface": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
+ "integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
@@ -13300,6 +13515,22 @@
"license": "MIT",
"peer": true
},
+ "node_modules/fast-uri": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
+ "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@@ -17511,6 +17742,32 @@
"react-native": "*"
}
},
+ "node_modules/react-native-purchases": {
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-10.4.2.tgz",
+ "integrity": "sha512-v/V3hGyBuGY6LvVRlBd5RmmQXMKK61CerRfMEIeYwz8VvtEQmy0qTYA3IImPf4qeEdykBTDv6+dqlz+QngkSiw==",
+ "license": "MIT",
+ "workspaces": [
+ "examples/purchaseTesterTypescript",
+ "react-native-purchases-store-galaxy",
+ "react-native-purchases-ui",
+ "e2e-tests/MaestroTestApp"
+ ],
+ "dependencies": {
+ "@revenuecat/purchases-js-hybrid-mappings": "18.19.0",
+ "@revenuecat/purchases-typescript-internal": "18.19.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.6.3",
+ "react-native": ">= 0.73.0",
+ "react-native-web": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-native-reanimated": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
diff --git a/package.json b/package.json
index 2d2867a..6a72a45 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"expo-auth-session": "~7.0.11",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
+ "expo-dev-client": "~6.0.21",
"expo-file-system": "~19.0.23",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
@@ -54,6 +55,7 @@
"react-native": "0.81.5",
"react-native-css": "^3.0.7",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-purchases": "10.4.2",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screen-transitions": "3.8.0",
diff --git a/providers/SubscriptionProvider.tsx b/providers/SubscriptionProvider.tsx
new file mode 100644
index 0000000..9491774
--- /dev/null
+++ b/providers/SubscriptionProvider.tsx
@@ -0,0 +1,234 @@
+import { useAuth } from "@clerk/expo";
+import type { ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import Purchases, {
+ type CustomerInfo,
+ type CustomerInfoUpdateListener,
+ type PurchasesOfferings,
+ type PurchasesPackage,
+} from "react-native-purchases";
+
+import { SubscriptionContext } from "@/hooks/useSubscription";
+import {
+ configureRevenueCat,
+ getFriendlyRevenueCatError,
+ getRevenueCatApiKey,
+ hasProEntitlement,
+} from "@/lib/subscription/revenueCat";
+
+export function SubscriptionProvider({ children }: { children: ReactNode }) {
+ const { isLoaded, userId } = useAuth();
+ const [customerInfo, setCustomerInfo] = useState(null);
+ const [offerings, setOfferings] = useState(null);
+ const [error, setError] = useState(null);
+ const [isConfigured, setIsConfigured] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const configuredRef = useRef(false);
+ const activeUserIdRef = useRef(null);
+ const apiKey = getRevenueCatApiKey();
+
+ const resetCustomerState = useCallback(() => {
+ activeUserIdRef.current = null;
+ setCustomerInfo(null);
+ setOfferings(null);
+ setError(null);
+ }, []);
+
+ const loadRevenueCatState = useCallback(async () => {
+ if (!configuredRef.current) {
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [latestCustomerInfo, latestOfferings] = await Promise.all([
+ Purchases.getCustomerInfo(),
+ Purchases.getOfferings(),
+ ]);
+
+ setCustomerInfo(latestCustomerInfo);
+ setOfferings(latestOfferings);
+ } catch {
+ setError("Subscriptions are unavailable right now.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!isLoaded) {
+ setIsLoading(true);
+ return;
+ }
+
+ if (!apiKey) {
+ configuredRef.current = false;
+ setIsConfigured(false);
+ setIsLoading(false);
+ resetCustomerState();
+ setError("RevenueCat is not configured for this build.");
+ return;
+ }
+
+ if (!configuredRef.current) {
+ configureRevenueCat(apiKey, userId ?? undefined);
+ configuredRef.current = true;
+ setIsConfigured(true);
+ }
+
+ let isActive = true;
+
+ async function identifyCustomer() {
+ setIsLoading(true);
+ setCustomerInfo(null);
+ setError(null);
+
+ try {
+ if (userId) {
+ activeUserIdRef.current = userId;
+ const result = await Purchases.logIn(userId);
+
+ if (!isActive || activeUserIdRef.current !== userId) {
+ return;
+ }
+
+ setCustomerInfo(result.customerInfo);
+ } else {
+ activeUserIdRef.current = null;
+ await Purchases.logOut().catch(() => undefined);
+
+ if (!isActive) {
+ return;
+ }
+
+ setCustomerInfo(null);
+ }
+
+ const latestOfferings = await Purchases.getOfferings();
+
+ if (isActive) {
+ setOfferings(latestOfferings);
+ }
+ } catch {
+ if (isActive) {
+ setError("Subscriptions are unavailable right now.");
+ setOfferings(null);
+ setCustomerInfo(null);
+ }
+ } finally {
+ if (isActive) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void identifyCustomer();
+
+ return () => {
+ isActive = false;
+ };
+ }, [apiKey, isLoaded, resetCustomerState, userId]);
+
+ useEffect(() => {
+ if (!isConfigured) {
+ return;
+ }
+
+ const listener: CustomerInfoUpdateListener = (nextCustomerInfo) => {
+ setCustomerInfo(nextCustomerInfo);
+ };
+
+ Purchases.addCustomerInfoUpdateListener(listener);
+
+ return () => {
+ Purchases.removeCustomerInfoUpdateListener(listener);
+ };
+ }, [isConfigured]);
+
+ const refresh = useCallback(async () => {
+ await loadRevenueCatState();
+ }, [loadRevenueCatState]);
+
+ const purchasePackage = useCallback(
+ async (selectedPackage: PurchasesPackage) => {
+ if (!configuredRef.current) {
+ throw new Error("Subscriptions are not configured for this build.");
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const result = await Purchases.purchasePackage(selectedPackage);
+
+ setCustomerInfo(result.customerInfo);
+ await loadRevenueCatState();
+ return result.customerInfo;
+ } catch (purchaseError) {
+ const message = getFriendlyRevenueCatError(purchaseError);
+
+ setError(message);
+ throw new Error(message);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [loadRevenueCatState],
+ );
+
+ const restorePurchases = useCallback(async () => {
+ if (!configuredRef.current) {
+ throw new Error("Subscriptions are not configured for this build.");
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const restoredCustomerInfo = await Purchases.restorePurchases();
+
+ setCustomerInfo(restoredCustomerInfo);
+ await loadRevenueCatState();
+ return restoredCustomerInfo;
+ } catch (restoreError) {
+ const message = getFriendlyRevenueCatError(restoreError);
+
+ setError(message);
+ throw new Error(message);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [loadRevenueCatState]);
+
+ const value = useMemo(
+ () => ({
+ customerInfo,
+ error,
+ isConfigured,
+ isLoading,
+ isPro: hasProEntitlement(customerInfo),
+ offerings,
+ purchasePackage,
+ refresh,
+ restorePurchases,
+ }),
+ [
+ customerInfo,
+ error,
+ isConfigured,
+ isLoading,
+ offerings,
+ purchasePackage,
+ refresh,
+ restorePurchases,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/store/useAIUsageStore.ts b/store/useAIUsageStore.ts
new file mode 100644
index 0000000..b5396ab
--- /dev/null
+++ b/store/useAIUsageStore.ts
@@ -0,0 +1,103 @@
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+import {
+ getCurrentUTCMonthKey,
+ type PremiumFeature,
+} from "@/lib/subscription/constants";
+import { createPersistStorage } from "@/lib/storage/createPersistStorage";
+import { reportAppError } from "@/lib/errors/reportAppError";
+
+type UserUsageByFeature = Partial>;
+type UsageByPeriod = Record;
+type UsageByUser = Record;
+
+type AIUsageState = {
+ getMonthlyUsage: (
+ userId: string | null | undefined,
+ feature: PremiumFeature,
+ periodKey?: string,
+ ) => number;
+ hasHydrated: boolean;
+ hydrationError: string | null;
+ incrementMonthlyUsage: (
+ userId: string,
+ feature: PremiumFeature,
+ amount?: number,
+ periodKey?: string,
+ ) => void;
+ usageByUser: UsageByUser;
+};
+
+const storageVersion = 1;
+
+export const useAIUsageStore = create()(
+ persist(
+ (set, get) => ({
+ getMonthlyUsage: (userId, feature, periodKey = getCurrentUTCMonthKey()) => {
+ if (!userId) {
+ return 0;
+ }
+
+ return get().usageByUser[userId]?.[periodKey]?.[feature] ?? 0;
+ },
+ hasHydrated: false,
+ hydrationError: null,
+ incrementMonthlyUsage: (
+ userId,
+ feature,
+ amount = 1,
+ periodKey = getCurrentUTCMonthKey(),
+ ) => {
+ if (!userId || amount <= 0) {
+ return;
+ }
+
+ set((state) => {
+ const userUsage = state.usageByUser[userId] ?? {};
+ const periodUsage = userUsage[periodKey] ?? {};
+ const currentCount = periodUsage[feature] ?? 0;
+
+ return {
+ usageByUser: {
+ ...state.usageByUser,
+ [userId]: {
+ ...userUsage,
+ [periodKey]: {
+ ...periodUsage,
+ [feature]: currentCount + amount,
+ },
+ },
+ },
+ };
+ });
+ },
+ usageByUser: {},
+ }),
+ {
+ name: "dear-diary-ai-usage-v1",
+ onRehydrateStorage: () => (state, error) => {
+ if (!state) {
+ return;
+ }
+
+ if (error) {
+ reportAppError(error, {
+ feature: "subscription",
+ operation: "local_hydration_ai_usage",
+ });
+ }
+
+ useAIUsageStore.setState({
+ hasHydrated: true,
+ hydrationError: error
+ ? "AI usage counters could not be loaded on this device."
+ : null,
+ });
+ },
+ partialize: (state) => ({ usageByUser: state.usageByUser }),
+ storage: createJSONStorage(() => createPersistStorage()),
+ version: storageVersion,
+ },
+ ),
+);
diff --git a/supabase/functions/_shared/subscriptionAccess.ts b/supabase/functions/_shared/subscriptionAccess.ts
new file mode 100644
index 0000000..5ff76e4
--- /dev/null
+++ b/supabase/functions/_shared/subscriptionAccess.ts
@@ -0,0 +1,401 @@
+import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+
+export type AIFeature =
+ | "ai_chat"
+ | "entry_reflection"
+ | "weekly_report"
+ | "monthly_report";
+
+type FeatureLimit = {
+ freeLimit: number;
+ proLimit: number | null;
+};
+
+type UsageAccessAllowed = {
+ isPro: boolean;
+ ok: true;
+ reservation: AIUsageReservation;
+};
+
+type UsageAccessDenied = {
+ body: {
+ code: string;
+ feature: AIFeature;
+ limit: number | null;
+ period: "monthly";
+ requestId: string;
+ };
+ ok: false;
+ status: number;
+};
+
+type UsageRpcResult = {
+ allowed: boolean;
+ code: string;
+ count: number;
+ feature: AIFeature;
+ limit: number | null;
+ period: "monthly";
+};
+
+export type AIUsageReservation = {
+ feature: AIFeature;
+ periodKey: string;
+ userId: string;
+};
+
+const entitlementId = "DearDiary Pro";
+const revenueCatSubscriberEndpoint = "https://api.revenuecat.com/v1/subscribers";
+const revenueCatFallbackTimeoutMs = 3_500;
+const revenueCatFallbackCacheLifetimeMs = 5 * 60 * 1000;
+const revenueCatFallbackCache = new Map<
+ string,
+ { expiresAt: number; isPro: boolean }
+>();
+
+const featureLimits: Record = {
+ ai_chat: {
+ freeLimit: 10,
+ proLimit: 300,
+ },
+ entry_reflection: {
+ freeLimit: 3,
+ proLimit: 100,
+ },
+ monthly_report: {
+ freeLimit: 0,
+ proLimit: null,
+ },
+ weekly_report: {
+ freeLimit: 1,
+ proLimit: null,
+ },
+};
+
+export function getUTCPeriodKey(date = new Date()) {
+ return date.toISOString().slice(0, 7);
+}
+
+export async function reserveAIUsageAccess(params: {
+ feature: AIFeature;
+ requestId: string;
+ userId: string;
+}): Promise {
+ const serviceClientResult = getServiceClient();
+
+ if (!serviceClientResult.ok) {
+ return {
+ body: {
+ code: "USAGE_LEDGER_UNAVAILABLE",
+ feature: params.feature,
+ limit: featureLimits[params.feature].freeLimit,
+ period: "monthly",
+ requestId: params.requestId,
+ },
+ ok: false,
+ status: 503,
+ };
+ }
+
+ const serviceClient = serviceClientResult.client;
+ const isPro = await resolveServerSideProEntitlement(
+ serviceClient,
+ params.userId,
+ );
+ const limit = featureLimits[params.feature];
+ const periodKey = getUTCPeriodKey();
+ const { data, error } = await serviceClient.rpc(
+ "increment_ai_usage_if_allowed",
+ {
+ p_feature: params.feature,
+ p_free_limit: limit.freeLimit,
+ p_is_pro: isPro,
+ p_period_key: periodKey,
+ p_pro_limit: limit.proLimit,
+ p_user_id: params.userId,
+ },
+ );
+
+ if (error) {
+ console.error("ai_usage_ledger_rpc_failed", {
+ code: error.code,
+ feature: params.feature,
+ requestId: params.requestId,
+ });
+
+ return {
+ body: {
+ code: "USAGE_LEDGER_UNAVAILABLE",
+ feature: params.feature,
+ limit: isPro ? limit.proLimit : limit.freeLimit,
+ period: "monthly",
+ requestId: params.requestId,
+ },
+ ok: false,
+ status: 503,
+ };
+ }
+
+ const usageResult = parseUsageRpcResult(data, params.feature);
+
+ if (!usageResult) {
+ return {
+ body: {
+ code: "USAGE_LEDGER_UNAVAILABLE",
+ feature: params.feature,
+ limit: isPro ? limit.proLimit : limit.freeLimit,
+ period: "monthly",
+ requestId: params.requestId,
+ },
+ ok: false,
+ status: 503,
+ };
+ }
+
+ if (!usageResult.allowed) {
+ return {
+ body: {
+ code: usageResult.code,
+ feature: params.feature,
+ limit: usageResult.limit,
+ period: "monthly",
+ requestId: params.requestId,
+ },
+ ok: false,
+ status: 402,
+ };
+ }
+
+ return {
+ isPro,
+ ok: true,
+ reservation: {
+ feature: params.feature,
+ periodKey,
+ userId: params.userId,
+ },
+ };
+}
+
+export async function finalizeAIUsageReservation(
+ _reservation: AIUsageReservation,
+) {
+ return;
+}
+
+export async function releaseAIUsageReservation(
+ reservation: AIUsageReservation,
+ requestId: string,
+) {
+ const serviceClientResult = getServiceClient();
+
+ if (!serviceClientResult.ok) {
+ console.error("ai_usage_reservation_release_unavailable", {
+ feature: reservation.feature,
+ requestId,
+ });
+ return;
+ }
+
+ const { error } = await serviceClientResult.client.rpc(
+ "release_ai_usage_reservation",
+ {
+ p_feature: reservation.feature,
+ p_period_key: reservation.periodKey,
+ p_user_id: reservation.userId,
+ },
+ );
+
+ if (error) {
+ console.error("ai_usage_reservation_release_failed", {
+ code: error.code,
+ feature: reservation.feature,
+ requestId,
+ });
+ }
+}
+
+function getServiceClient():
+ | { client: SupabaseClient; ok: true }
+ | { ok: false } {
+ const supabaseUrl = Deno.env.get("SUPABASE_URL");
+ const serviceRoleKey =
+ Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ??
+ Deno.env.get("SUPABASE_SECRET_KEY");
+
+ if (!supabaseUrl || !serviceRoleKey) {
+ return { ok: false };
+ }
+
+ return {
+ client: createClient(supabaseUrl, serviceRoleKey, {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ },
+ }),
+ ok: true,
+ };
+}
+
+async function resolveServerSideProEntitlement(
+ serviceClient: SupabaseClient,
+ userId: string,
+) {
+ const mirroredStatus = await getMirroredProStatus(serviceClient, userId);
+
+ if (mirroredStatus === true) {
+ return true;
+ }
+
+ return getCachedRevenueCatProStatus(userId);
+}
+
+async function getMirroredProStatus(
+ serviceClient: SupabaseClient,
+ userId: string,
+) {
+ const { data, error } = await serviceClient
+ .from("subscription_status")
+ .select("is_active,expires_at")
+ .eq("user_id", userId)
+ .eq("entitlement_id", entitlementId)
+ .maybeSingle();
+
+ if (error || !isRecord(data)) {
+ return null;
+ }
+
+ if (data.is_active !== true) {
+ return false;
+ }
+
+ if (typeof data.expires_at !== "string") {
+ return true;
+ }
+
+ const expiresAt = Date.parse(data.expires_at);
+
+ return Number.isFinite(expiresAt) && expiresAt > Date.now();
+}
+
+async function getCachedRevenueCatProStatus(userId: string) {
+ const cachedStatus = revenueCatFallbackCache.get(userId);
+
+ if (cachedStatus && cachedStatus.expiresAt > Date.now()) {
+ return cachedStatus.isPro;
+ }
+
+ const revenueCatStatus = await getRevenueCatProStatus(userId);
+ const isPro = revenueCatStatus ?? false;
+
+ revenueCatFallbackCache.set(userId, {
+ expiresAt: Date.now() + revenueCatFallbackCacheLifetimeMs,
+ isPro,
+ });
+
+ return isPro;
+}
+
+async function getRevenueCatProStatus(userId: string) {
+ const revenueCatSecretKey = Deno.env.get("REVENUECAT_SECRET_API_KEY");
+
+ if (!revenueCatSecretKey) {
+ return null;
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(
+ () => controller.abort(),
+ revenueCatFallbackTimeoutMs,
+ );
+
+ try {
+ const response = await fetch(
+ `${revenueCatSubscriberEndpoint}/${encodeURIComponent(userId)}`,
+ {
+ headers: {
+ Authorization: `Bearer ${revenueCatSecretKey}`,
+ "Content-Type": "application/json",
+ },
+ method: "GET",
+ signal: controller.signal,
+ },
+ );
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const body: unknown = await response.json();
+
+ return hasActiveRevenueCatEntitlement(body);
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function hasActiveRevenueCatEntitlement(body: unknown) {
+ if (!isRecord(body) || !isRecord(body.subscriber)) {
+ return false;
+ }
+
+ const entitlements = body.subscriber.entitlements;
+
+ if (!isRecord(entitlements)) {
+ return false;
+ }
+
+ const proEntitlement = entitlements[entitlementId];
+
+ if (!isRecord(proEntitlement)) {
+ return false;
+ }
+
+ const expiresDate = proEntitlement.expires_date;
+
+ if (expiresDate === null) {
+ return true;
+ }
+
+ if (typeof expiresDate !== "string") {
+ return false;
+ }
+
+ const expiresAt = Date.parse(expiresDate);
+
+ return Number.isFinite(expiresAt) && expiresAt > Date.now();
+}
+
+function parseUsageRpcResult(
+ value: unknown,
+ feature: AIFeature,
+): UsageRpcResult | null {
+ if (!isRecord(value)) {
+ return null;
+ }
+
+ if (
+ typeof value.allowed !== "boolean" ||
+ typeof value.code !== "string" ||
+ value.feature !== feature ||
+ value.period !== "monthly"
+ ) {
+ return null;
+ }
+
+ return {
+ allowed: value.allowed,
+ code: value.code,
+ count: typeof value.count === "number" ? value.count : 0,
+ feature,
+ limit: typeof value.limit === "number" ? value.limit : null,
+ period: "monthly",
+ };
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/supabase/functions/generate-insight-report/index.ts b/supabase/functions/generate-insight-report/index.ts
index 2bab6e3..6407edc 100644
--- a/supabase/functions/generate-insight-report/index.ts
+++ b/supabase/functions/generate-insight-report/index.ts
@@ -11,6 +11,11 @@ import {
parseReportNarrative,
type ReportNarrative,
} from "../_shared/parseReportNarrative.ts";
+import {
+ finalizeAIUsageReservation,
+ releaseAIUsageReservation,
+ reserveAIUsageAccess,
+} from "../_shared/subscriptionAccess.ts";
const corsHeaders = {
"Access-Control-Allow-Headers":
@@ -351,6 +356,18 @@ Deno.serve(async (request) => {
);
}
+ const usageAccess = await reserveAIUsageAccess({
+ feature:
+ reportRequest.periodType === "weekly" ? "weekly_report" : "monthly_report",
+ requestId,
+ userId: claims.sub,
+ });
+
+ if (!usageAccess.ok) {
+ return jsonResponse(usageAccess.body, usageAccess.status);
+ }
+ const usageReservation = usageAccess.reservation;
+
const prompt = buildNarrativePrompt({
analytics,
entries,
@@ -384,6 +401,8 @@ Deno.serve(async (request) => {
requestId,
});
+ await releaseAIUsageReservation(usageReservation, requestId);
+
return jsonResponse(
{
code:
@@ -450,6 +469,8 @@ Deno.serve(async (request) => {
requestId,
});
+ await releaseAIUsageReservation(usageReservation, requestId);
+
return jsonResponse(
{
code: "report_upsert_failed",
@@ -463,6 +484,8 @@ Deno.serve(async (request) => {
const report = mapReportRow(upsertResult.data);
if (!report) {
+ await releaseAIUsageReservation(usageReservation, requestId);
+
return jsonResponse(
{
code: "invalid_saved_report",
@@ -475,6 +498,8 @@ Deno.serve(async (request) => {
console.info("generate-insight-report report_saved", { requestId });
+ await finalizeAIUsageReservation(usageReservation);
+
return jsonResponse({ report, requestId });
});
diff --git a/supabase/functions/journal-ai-chat/index.ts b/supabase/functions/journal-ai-chat/index.ts
index d44d0ae..1971729 100644
--- a/supabase/functions/journal-ai-chat/index.ts
+++ b/supabase/functions/journal-ai-chat/index.ts
@@ -1,5 +1,10 @@
import { createClient } from "@supabase/supabase-js";
+import {
+ releaseAIUsageReservation,
+ reserveAIUsageAccess,
+} from "../_shared/subscriptionAccess.ts";
+
const corsHeaders = {
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
@@ -480,6 +485,17 @@ Deno.serve(async (request) => {
requestId,
});
+ const usageAccess = await reserveAIUsageAccess({
+ feature: "ai_chat",
+ requestId,
+ userId: authClaims.sub,
+ });
+
+ if (!usageAccess.ok) {
+ return jsonResponse(usageAccess.body, usageAccess.status);
+ }
+ const usageReservation = usageAccess.reservation;
+
try {
const assistantMessage = await callAIProvider(summaryPrompt, {
maxTokens: 950,
@@ -528,6 +544,8 @@ Deno.serve(async (request) => {
requestId,
});
+ await releaseAIUsageReservation(usageReservation, requestId);
+
return jsonResponse(
{
error: "The AI service is temporarily unavailable.",
@@ -568,6 +586,17 @@ Deno.serve(async (request) => {
useJournalContext,
});
+ const usageAccess = await reserveAIUsageAccess({
+ feature: "ai_chat",
+ requestId,
+ userId: authClaims.sub,
+ });
+
+ if (!usageAccess.ok) {
+ return jsonResponse(usageAccess.body, usageAccess.status);
+ }
+ const usageReservation = usageAccess.reservation;
+
try {
const assistantMessage = await callAIProvider(finalPrompt, { requestId });
@@ -607,6 +636,8 @@ Deno.serve(async (request) => {
requestId,
});
+ await releaseAIUsageReservation(usageReservation, requestId);
+
return jsonResponse(
{
error: "The AI service is temporarily unavailable.",
diff --git a/supabase/functions/reflect-on-entry/index.ts b/supabase/functions/reflect-on-entry/index.ts
index 68e666f..48ff1e5 100644
--- a/supabase/functions/reflect-on-entry/index.ts
+++ b/supabase/functions/reflect-on-entry/index.ts
@@ -1,5 +1,11 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+import {
+ finalizeAIUsageReservation,
+ releaseAIUsageReservation,
+ reserveAIUsageAccess,
+} from "../_shared/subscriptionAccess.ts";
+
const corsHeaders = {
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
@@ -299,6 +305,17 @@ Deno.serve(async (request) => {
});
}
+ const usageAccess = await reserveAIUsageAccess({
+ feature: "entry_reflection",
+ requestId,
+ userId: claims.sub,
+ });
+
+ if (!usageAccess.ok) {
+ return jsonResponse(usageAccess.body, usageAccess.status);
+ }
+ const usageReservation = usageAccess.reservation;
+
let reflectionResult: ValidReflectionResult;
try {
@@ -321,6 +338,8 @@ Deno.serve(async (request) => {
requestId,
});
+ await releaseAIUsageReservation(usageReservation, requestId);
+
return jsonResponse(
{
error:
@@ -372,6 +391,8 @@ Deno.serve(async (request) => {
requestId,
});
+ await releaseAIUsageReservation(usageReservation, requestId);
+
return jsonResponse(
{
error: "Reflection could not be saved.",
@@ -384,6 +405,8 @@ Deno.serve(async (request) => {
console.info("reflect-on-entry reflection_saved", { requestId });
+ await finalizeAIUsageReservation(usageReservation);
+
return jsonResponse({
reflection: mapEntryAIReflectionRow(upsertResult.data),
requestId,
diff --git a/supabase/migrations/20260712080005_add_ai_usage_subscription_gates.sql b/supabase/migrations/20260712080005_add_ai_usage_subscription_gates.sql
new file mode 100644
index 0000000..eb0e950
--- /dev/null
+++ b/supabase/migrations/20260712080005_add_ai_usage_subscription_gates.sql
@@ -0,0 +1,233 @@
+create extension if not exists "pgcrypto";
+
+create table if not exists public.subscription_status (
+ user_id text not null references public.profiles(id) on delete cascade,
+ entitlement_id text not null,
+ is_active boolean not null default false,
+ product_id text,
+ store text,
+ expires_at timestamptz,
+ will_renew boolean,
+ last_event_at timestamptz,
+ updated_at timestamptz not null default now(),
+
+ primary key (user_id, entitlement_id)
+);
+
+create index if not exists subscription_status_active_idx
+on public.subscription_status(user_id, entitlement_id, is_active);
+
+alter table public.subscription_status enable row level security;
+
+grant select on table public.subscription_status to authenticated;
+
+drop policy if exists "Users can select own subscription status"
+on public.subscription_status;
+
+create policy "Users can select own subscription status"
+on public.subscription_status
+for select
+to authenticated
+using (user_id = requesting_user_id());
+
+create table if not exists public.ai_usage_ledger (
+ id text primary key,
+ user_id text not null references public.profiles(id) on delete cascade,
+ period_key text not null,
+ feature text not null,
+ count integer not null default 0 check (count >= 0),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+
+ unique (user_id, period_key, feature)
+);
+
+create index if not exists ai_usage_ledger_user_period_idx
+on public.ai_usage_ledger(user_id, period_key);
+
+alter table public.ai_usage_ledger enable row level security;
+
+grant select on table public.ai_usage_ledger to authenticated;
+
+drop policy if exists "Users can select own AI usage"
+on public.ai_usage_ledger;
+
+create policy "Users can select own AI usage"
+on public.ai_usage_ledger
+for select
+to authenticated
+using (user_id = requesting_user_id());
+
+create or replace function public.increment_ai_usage_if_allowed(
+ p_user_id text,
+ p_period_key text,
+ p_feature text,
+ p_is_pro boolean,
+ p_free_limit integer,
+ p_pro_limit integer default null
+)
+returns jsonb
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ selected_limit integer;
+ next_count integer;
+ current_count integer;
+ ledger_id text;
+begin
+ if p_user_id is null or length(trim(p_user_id)) = 0 then
+ return jsonb_build_object(
+ 'allowed', false,
+ 'code', 'INVALID_USAGE_REQUEST',
+ 'feature', p_feature,
+ 'limit', 0,
+ 'period', 'monthly'
+ );
+ end if;
+
+ if p_period_key !~ '^[0-9]{4}-[0-9]{2}$' then
+ return jsonb_build_object(
+ 'allowed', false,
+ 'code', 'INVALID_USAGE_REQUEST',
+ 'feature', p_feature,
+ 'limit', 0,
+ 'period', 'monthly'
+ );
+ end if;
+
+ selected_limit := case
+ when p_is_pro then p_pro_limit
+ else p_free_limit
+ end;
+
+ select coalesce(count, 0)
+ into current_count
+ from public.ai_usage_ledger
+ where user_id = p_user_id
+ and period_key = p_period_key
+ and feature = p_feature;
+
+ current_count := coalesce(current_count, 0);
+
+ if selected_limit is not null and selected_limit <= 0 then
+ return jsonb_build_object(
+ 'allowed', false,
+ 'code', 'QUOTA_EXHAUSTED',
+ 'count', current_count,
+ 'feature', p_feature,
+ 'limit', selected_limit,
+ 'period', 'monthly'
+ );
+ end if;
+
+ if selected_limit is not null and current_count >= selected_limit then
+ return jsonb_build_object(
+ 'allowed', false,
+ 'code',
+ case when p_is_pro
+ then 'PRO_FAIR_USE_EXHAUSTED'
+ else 'QUOTA_EXHAUSTED'
+ end,
+ 'count', current_count,
+ 'feature', p_feature,
+ 'limit', selected_limit,
+ 'period', 'monthly'
+ );
+ end if;
+
+ ledger_id := 'usage_' ||
+ encode(sha256((p_user_id || ':' || p_period_key || ':' || p_feature)::bytea), 'hex');
+
+ insert into public.ai_usage_ledger as ledger (
+ id,
+ user_id,
+ period_key,
+ feature,
+ count,
+ created_at,
+ updated_at
+ )
+ values (
+ ledger_id,
+ p_user_id,
+ p_period_key,
+ p_feature,
+ 1,
+ now(),
+ now()
+ )
+ on conflict (user_id, period_key, feature)
+ do update
+ set
+ count = ledger.count + 1,
+ updated_at = now()
+ where selected_limit is null or ledger.count < selected_limit
+ returning count into next_count;
+
+ if next_count is null then
+ select count
+ into current_count
+ from public.ai_usage_ledger
+ where user_id = p_user_id
+ and period_key = p_period_key
+ and feature = p_feature;
+
+ return jsonb_build_object(
+ 'allowed', false,
+ 'code',
+ case when p_is_pro
+ then 'PRO_FAIR_USE_EXHAUSTED'
+ else 'QUOTA_EXHAUSTED'
+ end,
+ 'count', coalesce(current_count, 0),
+ 'feature', p_feature,
+ 'limit', selected_limit,
+ 'period', 'monthly'
+ );
+ end if;
+
+ return jsonb_build_object(
+ 'allowed', true,
+ 'code', 'USAGE_RECORDED',
+ 'count', next_count,
+ 'feature', p_feature,
+ 'limit', selected_limit,
+ 'period', 'monthly'
+ );
+end;
+$$;
+
+revoke execute on function public.increment_ai_usage_if_allowed(
+ text,
+ text,
+ text,
+ boolean,
+ integer,
+ integer
+) from public;
+revoke execute on function public.increment_ai_usage_if_allowed(
+ text,
+ text,
+ text,
+ boolean,
+ integer,
+ integer
+) from anon;
+revoke execute on function public.increment_ai_usage_if_allowed(
+ text,
+ text,
+ text,
+ boolean,
+ integer,
+ integer
+) from authenticated;
+grant execute on function public.increment_ai_usage_if_allowed(
+ text,
+ text,
+ text,
+ boolean,
+ integer,
+ integer
+) to service_role;
diff --git a/supabase/migrations/20260715174319_add_ai_usage_reservation_release.sql b/supabase/migrations/20260715174319_add_ai_usage_reservation_release.sql
new file mode 100644
index 0000000..3e8d5d6
--- /dev/null
+++ b/supabase/migrations/20260715174319_add_ai_usage_reservation_release.sql
@@ -0,0 +1,75 @@
+create or replace function public.release_ai_usage_reservation(
+ p_user_id text,
+ p_period_key text,
+ p_feature text
+)
+returns jsonb
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ next_count integer;
+begin
+ if p_user_id is null or length(trim(p_user_id)) = 0 then
+ return jsonb_build_object(
+ 'released', false,
+ 'code', 'INVALID_USAGE_RELEASE',
+ 'feature', p_feature,
+ 'period', 'monthly'
+ );
+ end if;
+
+ if p_period_key !~ '^[0-9]{4}-[0-9]{2}$' then
+ return jsonb_build_object(
+ 'released', false,
+ 'code', 'INVALID_USAGE_RELEASE',
+ 'feature', p_feature,
+ 'period', 'monthly'
+ );
+ end if;
+
+ update public.ai_usage_ledger
+ set
+ count = greatest(count - 1, 0),
+ updated_at = now()
+ where user_id = p_user_id
+ and period_key = p_period_key
+ and feature = p_feature
+ and count > 0
+ returning count into next_count;
+
+ return jsonb_build_object(
+ 'released', next_count is not null,
+ 'code',
+ case when next_count is null
+ then 'USAGE_RESERVATION_NOT_FOUND'
+ else 'USAGE_RESERVATION_RELEASED'
+ end,
+ 'count', coalesce(next_count, 0),
+ 'feature', p_feature,
+ 'period', 'monthly'
+ );
+end;
+$$;
+
+revoke execute on function public.release_ai_usage_reservation(
+ text,
+ text,
+ text
+) from public;
+revoke execute on function public.release_ai_usage_reservation(
+ text,
+ text,
+ text
+) from anon;
+revoke execute on function public.release_ai_usage_reservation(
+ text,
+ text,
+ text
+) from authenticated;
+grant execute on function public.release_ai_usage_reservation(
+ text,
+ text,
+ text
+) to service_role;
diff --git a/tests/environment.test.ts b/tests/environment.test.ts
index 2dfe5bd..dc7f79d 100644
--- a/tests/environment.test.ts
+++ b/tests/environment.test.ts
@@ -112,6 +112,8 @@ assert(
"https://example.com/delete-account" &&
productionResult.environment.appEnvironment === "production" &&
productionResult.environment.clerkPublishableKey === "pk_live_example" &&
+ productionResult.environment.revenueCatAndroidApiKey === null &&
+ productionResult.environment.revenueCatIosApiKey === null &&
productionResult.environment.supabasePublicKey === "production-public-key" &&
productionResult.environment.supabaseUrl ===
"https://production.supabase.co",