diff --git a/app.config.js b/app.config.js
index dce6705..12172b6 100644
--- a/app.config.js
+++ b/app.config.js
@@ -1,12 +1,18 @@
-const RELEASE_PROFILES = new Set(["preview", "production"]);
+const {
+ resolveRevenueCatBuildConfigFromEnvironment,
+} = require("./lib/subscription/revenueCatBuildConfig");
module.exports = ({ config }) => {
- const shouldGenerateDevClientScheme = !RELEASE_PROFILES.has(
- process.env.EAS_BUILD_PROFILE,
- );
+ const shouldGenerateDevClientScheme =
+ process.env.EAS_BUILD_PROFILE === "development";
+ const revenueCat = resolveRevenueCatBuildConfigFromEnvironment(process.env);
return {
...config,
+ extra: {
+ ...(config.extra || {}),
+ revenueCat,
+ },
plugins: (config.plugins || []).map((plugin) => {
const isDevClientPlugin =
plugin === "expo-dev-client" ||
diff --git a/app.json b/app.json
index abf58d7..0ef5822 100644
--- a/app.json
+++ b/app.json
@@ -2,14 +2,14 @@
"expo": {
"name": "DearDiary",
"slug": "dear-diary",
- "version": "1.0.8",
+ "version": "1.0.9",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "deardiary",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
- "buildNumber": "7",
+ "buildNumber": "9",
"supportsTablet": true
},
"android": {
@@ -22,7 +22,7 @@
"icon": "./assets/images/icon.png",
"package": "com.aryan.deardiary",
"permissions": ["com.android.vending.BILLING"],
- "versionCode": 8,
+ "versionCode": 9,
"adaptiveIcon": {
"backgroundColor": "#FFDDE8",
"foregroundImage": "./assets/images/icon.png"
diff --git a/components/ai-chat/ai-chat-screen.tsx b/components/ai-chat/ai-chat-screen.tsx
index 00606f8..6250145 100644
--- a/components/ai-chat/ai-chat-screen.tsx
+++ b/components/ai-chat/ai-chat-screen.tsx
@@ -6,7 +6,6 @@ import { StatusBar } from "expo-status-bar";
import { Sparkles } from "lucide-react-native";
import { useEffect, useMemo, useRef, useState } from "react";
import {
- Animated,
Keyboard,
KeyboardAvoidingView,
type NativeScrollEvent,
@@ -22,6 +21,7 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AiProcessingAnimation } from "@/components/ai-chat/ai-processing-animation";
import { AnimatedIconButton } from "@/components/ui/animated-icon-button";
import { AIResponseRenderer } from "@/components/ai/ai-response-renderer";
import { ScreenErrorState } from "@/components/states/ScreenErrorState";
@@ -581,7 +581,9 @@ export function AiChatScreen({
DearDiary AI
-
+
+
+
) : null}
@@ -712,58 +714,6 @@ function shouldCountChatIntentTowardQuota(
].includes(intent);
}
-function ThinkingText() {
- const opacity = useRef(new Animated.Value(0.42)).current;
- const translateY = useRef(new Animated.Value(0)).current;
-
- useEffect(() => {
- const animation = Animated.loop(
- Animated.sequence([
- Animated.parallel([
- Animated.timing(opacity, {
- duration: 620,
- toValue: 1,
- useNativeDriver: true,
- }),
- Animated.timing(translateY, {
- duration: 620,
- toValue: -1.5,
- useNativeDriver: true,
- }),
- ]),
- Animated.parallel([
- Animated.timing(opacity, {
- duration: 620,
- toValue: 0.42,
- useNativeDriver: true,
- }),
- Animated.timing(translateY, {
- duration: 620,
- toValue: 0,
- useNativeDriver: true,
- }),
- ]),
- ]),
- );
-
- animation.start();
-
- return () => animation.stop();
- }, [opacity, translateY]);
-
- return (
-
- Thinking...
-
- );
-}
-
function ChatBubble({
assistantBubbleMaxWidth,
avatarUrl,
diff --git a/components/ai-chat/ai-processing-animation.tsx b/components/ai-chat/ai-processing-animation.tsx
new file mode 100644
index 0000000..2812cb4
--- /dev/null
+++ b/components/ai-chat/ai-processing-animation.tsx
@@ -0,0 +1,283 @@
+import { useEffect, useId } from "react";
+import { View } from "react-native";
+import Animated, {
+ cancelAnimation,
+ Easing,
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+import Svg, {
+ Defs,
+ FeGaussianBlur,
+ Filter,
+ G,
+ LinearGradient,
+ Mask,
+ Path,
+ RadialGradient,
+ Stop,
+} from "react-native-svg";
+
+import { colors } from "@/constants/theme";
+
+const sparklePath =
+ "M63,37c-6.7-4-4-27-13-27s-6.3,23-13,27-27,4-27,13,20.3,9,27,13,4,27,13,27,6.3-23,13-27,27-4,27-13-20.3-9-27-13Z";
+const animationDuration = 1000;
+const animationStops = [0, 0.25, 0.5, 0.75, 1];
+
+type SparkleDirection = "center" | "left" | "right";
+
+type AnimatedSparkleProps = {
+ delay: number;
+ direction: SparkleDirection;
+ idSuffix: string;
+ scale: number;
+};
+
+type AiProcessingAnimationProps = {
+ size?: "compact" | "default";
+};
+
+export function AiProcessingAnimation({
+ size = "default",
+}: AiProcessingAnimationProps) {
+ const idSuffix = useId().replace(/:/g, "");
+ const scale = size === "compact" ? 0.67 : 1;
+
+ return (
+
+
+
+
+
+ );
+}
+
+function AnimatedSparkle({
+ delay,
+ direction,
+ idSuffix,
+ scale,
+}: AnimatedSparkleProps) {
+ const progress = useSharedValue(0);
+ const gradientId = `ai-sparkle-${direction}-${idSuffix}`;
+
+ useEffect(() => {
+ progress.set(withDelay(
+ delay,
+ withRepeat(
+ withTiming(1, {
+ duration: animationDuration,
+ easing: Easing.linear,
+ }),
+ -1,
+ false,
+ ),
+ ));
+
+ return () => cancelAnimation(progress);
+ }, [delay, progress]);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const isCenter = direction === "center";
+ const horizontalMovement =
+ direction === "left"
+ ? [-14, -8, -4, 0, 4].map((value) => value * scale)
+ : direction === "right"
+ ? [14, 8, 4, 0, -4].map((value) => value * scale)
+ : [0, 0, 0, 0, 0];
+ const rotation =
+ direction === "left"
+ ? [-10, -5, 0, 5, 10]
+ : direction === "right"
+ ? [10, 5, 0, -5, -10]
+ : [0, 0, 0, 0, 0];
+
+ return {
+ opacity: interpolate(
+ progress.value,
+ animationStops,
+ [0, 1, 1, 1, 0],
+ ),
+ transform: [
+ {
+ translateX: interpolate(
+ progress.value,
+ animationStops,
+ horizontalMovement,
+ ),
+ },
+ {
+ translateY: interpolate(
+ progress.value,
+ animationStops,
+ [-21, -10, 0, 7, 17].map((value) => value * scale),
+ ),
+ },
+ {
+ rotateZ: `${interpolate(
+ progress.value,
+ animationStops,
+ rotation,
+ )}deg`,
+ },
+ {
+ scale: interpolate(
+ progress.value,
+ animationStops,
+ isCenter ? [0.5, 0.75, 1, 0.5, 0] : [0.5, 1, 1, 0.5, 0],
+ ),
+ },
+ ],
+ };
+ });
+
+ return (
+
+
+
+ );
+}
+
+function Sparkle({ gradientId }: { gradientId: string }) {
+ const shadowGradientId = `${gradientId}-shadow`;
+ const topGradientId = `${gradientId}-top`;
+ const sideGradientId = `${gradientId}-side`;
+ const baseGradientId = `${gradientId}-base`;
+ const depthGradientId = `${gradientId}-depth`;
+ const shineFilterId = `${gradientId}-shine`;
+ const sparkleMaskId = `${gradientId}-mask`;
+
+ return (
+
+ );
+}
diff --git a/components/ai/ai-response-renderer.tsx b/components/ai/ai-response-renderer.tsx
index 9fd7ccd..bd74675 100644
--- a/components/ai/ai-response-renderer.tsx
+++ b/components/ai/ai-response-renderer.tsx
@@ -61,9 +61,11 @@ const variantClasses: Record<
};
const safeTextStyle = {
+ flexShrink: 1,
+ flexWrap: "wrap",
includeFontPadding: true,
+ maxWidth: "100%",
minWidth: 0,
- overflow: "visible",
} as const;
export const AIResponseRenderer = memo(function AIResponseRenderer({
@@ -288,7 +290,7 @@ function InlineMarkdownText({
}) {
const parts = useMemo(() => parseInlineMarkdown(text), [text]);
const getRenderedContent = (content: string) =>
- selectable ? content : addSafeBreakOpportunities(content);
+ addSafeBreakOpportunities(content);
return (
- {getRenderedContent(part.content)}
+ {part.content}
);
}
@@ -336,7 +338,7 @@ function InlineMarkdownText({
key={index}
onPress={() => openSafeLink(part.url)}
>
- {getRenderedContent(part.content)}
+ {part.content}
);
}
@@ -428,9 +430,7 @@ class AIResponseErrorBoundary extends Component<
style={safeTextStyle}
textBreakStrategy="highQuality"
>
- {this.props.selectable
- ? this.props.content
- : addSafeBreakOpportunities(this.props.content)}
+ {addSafeBreakOpportunities(this.props.content)}
);
}
diff --git a/components/journal-editor/entry-ai-reflection-card.tsx b/components/journal-editor/entry-ai-reflection-card.tsx
index 3e4dce6..1efcc5a 100644
--- a/components/journal-editor/entry-ai-reflection-card.tsx
+++ b/components/journal-editor/entry-ai-reflection-card.tsx
@@ -318,6 +318,8 @@ function ReflectionButton({
onPress: () => void;
testID?: string;
}) {
+ const isVisuallyDisabled = disabled && !isLoading;
+
return (
{isLoading ? (
-
+
) : (
icon
)}
[monthlyPackage, yearlyPackage].filter(isPurchasesPackage),
[monthlyPackage, yearlyPackage],
@@ -51,10 +58,15 @@ export function PaywallScreen({ feature }: PaywallScreenProps) {
availablePackages.some(
(availablePackage) =>
availablePackage.identifier === selectedPackage.identifier &&
- availablePackage.product.identifier === selectedPackage.product.identifier,
+ availablePackage.product.identifier ===
+ selectedPackage.product.identifier,
);
const continueDisabled =
- !isConfigured || !selectedPackageIsAvailable || isPurchasing || isRestoring;
+ !isConfigured ||
+ !selectedPackageIsAvailable ||
+ isLoading ||
+ isPurchasing ||
+ isRestoring;
useEffect(() => {
const defaultPackage = yearlyPackage ?? monthlyPackage ?? null;
@@ -91,7 +103,8 @@ export function PaywallScreen({ feature }: PaywallScreenProps) {
try {
const customerInfo = await purchasePackage(selectedPackage);
- if (hasProEntitlement(customerInfo)) {
+ // refreshCustomerForUser has already verified the active identity.
+ if (hasProEntitlement(customerInfo, true)) {
showDialog({
confirmText: "Done",
message:
@@ -122,7 +135,8 @@ export function PaywallScreen({ feature }: PaywallScreenProps) {
try {
const customerInfo = await restorePurchases();
- if (hasProEntitlement(customerInfo)) {
+ // refreshCustomerForUser has already verified the active identity.
+ if (hasProEntitlement(customerInfo, true)) {
showDialog({
confirmText: "Done",
message: "Subscription restored successfully.",
@@ -253,13 +267,21 @@ export function PaywallScreen({ feature }: PaywallScreenProps) {
testID="paywall-error-message"
message="Subscriptions are not configured for this build."
/>
- ) : !offering || availablePackages.length === 0 ? (
+ ) : !isLoading &&
+ (!offering || availablePackages.length === 0) ? (
void refresh()}
/>
) : error ? (
-
+ void refresh()}
+ />
) : null}
{statusMessage ? (
@@ -374,10 +396,14 @@ export function PaywallScreen({ feature }: PaywallScreenProps) {
}
function UnavailableMessage({
+ isRetrying = false,
message,
+ onRetry,
testID,
}: {
+ isRetrying?: boolean;
message: string;
+ onRetry?: () => void;
testID?: string;
}) {
return (
@@ -385,6 +411,19 @@ function UnavailableMessage({
{message}
+ {onRetry ? (
+
+
+ {isRetrying ? "Trying again..." : "Try again"}
+
+
+ ) : null}
);
}
@@ -394,23 +433,17 @@ function findPackage(
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") ||
+ candidate.packageType === PACKAGE_TYPE.MONTHLY ||
period === "P1M"
);
}
return (
- identifier.includes("yearly") ||
- identifier.includes("annual") ||
- productIdentifier.includes("yearly") ||
- productIdentifier.includes("annual") ||
+ candidate.packageType === PACKAGE_TYPE.ANNUAL ||
period === "P1Y"
);
}) ?? null;
diff --git a/docs/android-release-dependency-hardening.md b/docs/android-release-dependency-hardening.md
index 3167eca..b4a5f52 100644
--- a/docs/android-release-dependency-hardening.md
+++ b/docs/android-release-dependency-hardening.md
@@ -72,8 +72,8 @@ androidx.compose.ui
`app.config.js` preserves the complete static config passed from `app.json` and changes only the existing Expo Dev Client plugin option:
```text
-development or unspecified profile -> addGeneratedScheme: true
-preview/production -> addGeneratedScheme: false
+development profile -> addGeneratedScheme: true
+unspecified/preview/production -> addGeneratedScheme: false
```
A dynamic config was necessary because `addGeneratedScheme` must vary by `EAS_BUILD_PROFILE`. Using Expo Dev Client's own option prevents the scheme from being generated and avoids brittle post-generation intent-filter edits. The intentional `deardiary://` scheme, HTTPS filters, launcher filter, and Clerk `clerk://` callback filters are untouched.
diff --git a/docs/environment-matrix.md b/docs/environment-matrix.md
index eb1a020..d169717 100644
--- a/docs/environment-matrix.md
+++ b/docs/environment-matrix.md
@@ -2,7 +2,7 @@
## Development
-Used for local Metro-connected work, explicit developer diagnostics, and `__DEV__`-guarded fault injection. Local values belong in ignored `.env` files. The configured EAS development profile is a development client, but `expo-dev-client` is not installed; do not build that profile until the dependency is explicitly approved and added.
+Used for local Metro-connected work, RevenueCat Test Store testing, explicit developer diagnostics, and `__DEV__`-guarded fault injection. Local values belong in ignored `.env` files. The EAS Development profile is a debuggable Expo development client and is the only Test Store build path.
## Preview
@@ -21,6 +21,8 @@ Reserved for a future Play Store AAB using production services. Production value
| `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 |
+| `EXPO_PUBLIC_REVENUECAT_TEST_API_KEY` | Development Test Store public SDK key; configured in EAS | Not used | Must not be defined | Yes | Development purchases only |
+| `EXPO_PUBLIC_REVENUECAT_MODE` | `test-store` | `google-play` | `google-play` | Yes | Yes for purchase builds |
| `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 |
diff --git a/docs/preview-release-readiness.md b/docs/preview-release-readiness.md
index dea935d..0687aad 100644
--- a/docs/preview-release-readiness.md
+++ b/docs/preview-release-readiness.md
@@ -2,7 +2,9 @@
Audit date: 2026-07-20
-Branch/commit: `dev` at `2c6d79e623425cfe5af5a9db9b30757f7c21ffef`
+Preview artifact source: `dev` at `2c6d79e623425cfe5af5a9db9b30757f7c21ffef`
+
+RevenueCat preparation baseline: `dev` at `8b4c5b26f93db6a845cbc458c779200f69fe3173`
Candidate: source version 1.0.8, Android version code 8
@@ -29,6 +31,9 @@ This audit now includes the central deep-link security fix and a newly created P
| `report-analytics.test.ts` | Passed | Compiled and executed from a temporary directory |
| `report-narrative-parser.test.ts` | Passed | Compiled and executed from a temporary directory |
| `ai-text-rendering.test.ts` | Passed | Compiled and executed from a temporary directory |
+| `test:revenuecat-config` | Passed | Build mode/key isolation and Production fail-closed coverage |
+| `test:revenuecat-runtime` | Passed | Identity, stale state, request deduplication, and entitlement verification coverage |
+| `verify:revenuecat-config` | Passed | Redacted Development/Preview/Production effective profile report |
## RevenueCat release logging
@@ -39,6 +44,18 @@ This audit now includes the central deep-link security fix and a newly created P
| One-time configuration | Passed | A module-level guard prevents more than one `Purchases.configure` call per app process; the provider also retains its mount guard |
| Manual sensitive purchase logging | Passed | No purchase token, customer information, journal content, or entitlement payload is manually logged by the subscription/paywall source |
+## RevenueCat Test Store preparation
+
+The existing `development` profile is now the only Test Store path. It uses the Development EAS environment and produces a debuggable Expo development client. Preview and Production explicitly select `google-play` and remain release APK/AAB paths; only Development selects `test-store`. Dynamic Expo configuration exposes only the selected public SDK key and mode. Production rejects Test Store mode, a Test Store-looking selected key, and any Test Store key defined in the Production environment.
+
+`react-native-purchases@10.4.2` exceeds RevenueCat's current React Native Test Store minimum of 9.5.4, so no dependency upgrade or lockfile change was needed. The Clerk user ID remains the App User ID. Account transitions clear subscription state before synchronization; repeated login, anonymous logout, stale CustomerInfo publication, and duplicate in-flight offering work are guarded.
+
+The Test Store app/key presence, products, entitlement/package mapping, and current offering were verified through RevenueCat MCP without changing dashboard resources. Sandbox Testing Access remains the only manual RevenueCat dashboard check because MCP does not expose it. No APK was built. Full setup and runtime steps are in `docs/revenuecat-test-store-setup.md`.
+
+RevenueCat profile verdict: **REVENUECAT TEST STORE DEVELOPMENT PROFILE READY**
+
+This does not replace the overall Preview release verdict or promote any device-runtime row to Passed.
+
## Build preparation
| Required value | Classification | Verified value |
diff --git a/docs/revenuecat-preview-runtime-test.md b/docs/revenuecat-preview-runtime-test.md
index 45cfcb4..d4c8249 100644
--- a/docs/revenuecat-preview-runtime-test.md
+++ b/docs/revenuecat-preview-runtime-test.md
@@ -1,47 +1,129 @@
-# RevenueCat Preview runtime test
+# RevenueCat Preview and Test Store runtime test
-Audit date: 2026-07-19
+Audit date: 2026-07-22
-Candidate: source version 1.0.7, Android version code 7
+Branch/commit baseline: `dev` at `8b4c5b26f93db6a845cbc458c779200f69fe3173`
-Store environment: RevenueCat Test Store or authorized sandbox only
+Candidate: source version 1.0.9, Android version code 9, with uncommitted RevenueCat preparation changes
+
+## Configuration split
+
+| Profile | RevenueCat mode | Store key | Artifact | Status |
+|---|---|---|---|---|
+| `development` | `test-store` | Test Store public SDK key | Debug development-client APK | Configuration ready; build required |
+| `preview` | `google-play` | Android public SDK key | Internal APK | Existing behavior preserved |
+| `production` | `google-play` | Android public SDK key | AAB | Existing behavior preserved; Test Store rejected |
+
+No key value is in `eas.json`, runtime logs, tests, or documentation. Dynamic Expo configuration selects one key before bundling and exposes only the selected mode/key through `extra.revenueCat`.
+
+## Actual RevenueCat identifiers
+
+| Item | Source value |
+|---|---|
+| Entitlement | `DearDiary Pro` |
+| Offering | `default` |
+| Monthly Google Play product | `deardiary_pro_monthly` |
+| Yearly Google Play product | `deardiary_pro_yearly` |
+| Monthly package | RevenueCat predefined monthly slot/type |
+| Yearly package | RevenueCat predefined annual slot/type |
+
+Live RevenueCat MCP identifiers:
+
+| Item | Live resource |
+|---|---|
+| Project | `DearDiary` (`proj11badf66`) |
+| Android app/provider | `DearDiary Android` (`app43d78856cd`), `com.aryan.deardiary` |
+| Test Store app/provider | `Test Store` (`app4bec91040f`) |
+| Entitlement | `DearDiary Pro` (`entleefa70a2be`) |
+| Current offering | `default` (`ofrng0ecaeb85d3`) |
+| Test monthly product | `deardiary_pro_monthly` (`proda9ff3d9aa1`), `P1M` |
+| Test annual product | `deardiary_pro_yearly` (`prod1c44bca13c`), `P1Y` |
+| Monthly package | `$rc_monthly` (`pkge73858b426a`) |
+| Annual package | `$rc_annual` (`pkgef61fa3ca89`) |
+
+The Test Store public SDK key exists for the Test Store app and its project association was verified. Its value remains redacted. The existing dashboard state already satisfied all supported requirements, so RevenueCat MCP performed no writes and created no duplicate resources. Both predefined packages retain their Google Play products and now-verified Test Store products, and all four active products remain attached to `DearDiary Pro`.
+
+The product identifiers remain unchanged. Paywall selection prefers `offering.monthly` and `offering.annual`, then RevenueCat package type/subscription period. Prices continue to use RevenueCat’s localized `priceString`; Test Store product IDs do not need to match Google Play IDs when dashboard packages are mapped correctly.
## Source audit
| Check | Classification | Evidence |
|---|---|---|
-| Development logging | Passed | `LOG_LEVEL.DEBUG` is selected only when `__DEV__` is true |
-| Preview/Production logging | Passed | Non-development builds select `LOG_LEVEL.WARN` |
-| Configure once | Passed | A module-level guard allows one `Purchases.configure` call per app process; the provider retains a mount guard |
-| Active Clerk identity | Passed | `Purchases.logIn(userId)` follows the loaded signed-in user |
-| Signed-out identity | Passed | `Purchases.logOut()` runs for the signed-out state and customer state is cleared before the result is accepted |
-| Stale async identity result | Passed | Results are ignored when the active user changes before completion |
-| Sensitive manual logging | Passed | No purchase token, `CustomerInfo`, entitlement payload, package payload, or journal content is manually logged |
-| Core journaling remains free | Passed | Source feature gates do not wrap journal CRUD |
-| App Lock remains free | Passed | Source feature gates do not wrap App Lock |
-| Account deletion remains free | Passed | Source feature gates do not wrap account deletion |
-
-These are source-level results. Identity and entitlement isolation still require the runtime matrix below.
+| Installed SDK | Passed | `react-native-purchases@10.4.2`; current documented Test Store minimum is 9.5.4 |
+| Dependency change | Passed | No SDK upgrade or dependency-lock change required |
+| Configure location | Passed | `SubscriptionProvider` calls the module-level `configureRevenueCat()` helper after Clerk loads |
+| Configure once | Passed | Module-level run-once guard plus provider mount guard allows one configure action per JS process |
+| Release logging | Passed | `LOG_LEVEL.WARN` outside `__DEV__`; development remains `DEBUG` |
+| API-key logging | Passed | No selected key or key payload is logged |
+| Configuration unavailable | Passed | Provider exposes a safe unavailable state; core app remains mounted |
+| Clerk mapping | Passed | Stable Clerk `userId` is the RevenueCat App User ID; no email or user-visible data is used |
+| Repeated sign-in | Passed | Current RevenueCat App User ID is checked before `logIn()` |
+| Anonymous sign-out | Passed | `isAnonymous()` is checked before `logOut()`; genuine synchronization errors reach the unavailable state |
+| Account switching | Passed | Subscription state clears first; identified User A → User B calls `logIn(B)` directly and publishes only after ID confirmation |
+| Stale identity results | Passed | Identity version, active Clerk ID, synchronized ID, and SDK App User ID gate state publication |
+| CustomerInfo listener | Passed | One provider listener; removed on cleanup; each update confirms the active RevenueCat/Clerk identity |
+| Offering deduplication | Passed | In-flight requests are shared per mode/user identity key; failures clear the request so explicit retry can recover |
+| CustomerInfo deduplication | Passed | Concurrent same-mode/same-user refreshes share an in-flight request |
+| Purchase success | Passed at source level | Purchase is followed by App User ID confirmation and fresh CustomerInfo; only active `DearDiary Pro` unlocks access |
+| Purchase failure/cancel | Passed at source level | Safe messages, no CustomerInfo-based entitlement grant, retry remains available |
+| Restore | Passed at source level | Restore is followed by identity confirmation and fresh CustomerInfo; inactive entitlement remains Free |
+| Renewal/expiration | Passed at source level | Verified listener/refresh updates replace same-user CustomerInfo; inactive entitlement removes Plus only |
+| Expo Go | Passed at source level | `storeClient` execution is explicitly non-authoritative for entitlement access |
+| Server quotas | Preserved | Existing Supabase subscription lookup and AI usage ledger remain unchanged |
+| Core journaling/App Lock/deletion | Preserved | These surfaces remain outside subscription feature gates |
+
+These are source-level results, not a runtime purchase pass.
+
+## Automated verification
+
+| Check | Result |
+|---|---|
+| `npx expo-doctor` | Passed: 18/18 |
+| `npx tsc --noEmit` | Passed |
+| `npm run lint` | Passed |
+| Existing scripted tests | Passed after implementation |
+| Standalone analytics/text tests | Passed after temporary full-project compile with alias links in `/tmp` |
+| `test:revenuecat-config` | Passed |
+| `test:revenuecat-runtime` | Passed |
+| `verify:revenuecat-config` | Passed with redacted metadata only |
+| `test:environment` | Passed |
+| `test:navigation-transitions` | Passed |
+| `test:route-security` | Passed |
## Runtime matrix
| Check | Classification | Notes |
|---|---|---|
-| Monthly package | Blocked by environment | Requires Test Store or authorized sandbox configuration on the current artifact |
-| Yearly package | Blocked by environment | Requires Test Store or authorized sandbox configuration on the current artifact |
-| Successful purchase | Pending manual verification | Do not use a live charge |
-| Cancelled purchase | Pending manual verification | App must remain usable and show safe feedback |
-| Failed purchase | Pending manual verification | No crash, stale entitlement, or sensitive logging |
-| Restore | Pending manual verification | Restore only in the authorized test environment |
-| Restart persistence | Pending manual verification | Entitlement survives process kill/restart correctly |
-| Entitlement expiration | Pending manual verification | Plus access is removed while core free features remain available |
-| User A Plus / User B Free | Pending manual verification | User B must not inherit User A's entitlement |
-| Return from User B to User A | Pending manual verification | User A entitlement restores without exposing User B state |
-| Core journaling free | Pending manual verification | Confirm runtime access without Plus |
-| App Lock free | Pending manual verification | Confirm runtime access without Plus |
-| Account deletion free | Pending manual verification | Confirm runtime access without Plus |
-| Google Play Billing in sideloaded APK | Not applicable | A sideload must not be claimed as Google Play Billing validation |
-
-Runtime execution is currently **blocked by environment** because the 1.0.7/code 7 candidate was not built or installed and no authorized Test Store/Internal Testing context was used.
-
-Do not print purchase tokens or customer/entitlement payloads. Claim Google Play Billing validation only for an APK installed through Google Play Internal Testing.
+| Monthly Test Store package | Dashboard passed; runtime pending | `$rc_monthly` maps to the active `P1M` Test product and preserves Google Play; requires Development build |
+| Yearly Test Store package | Dashboard passed; runtime pending | `$rc_annual` maps to the active `P1Y` Test product and preserves Google Play; requires Development build |
+| Successful purchase | Pending manual verification | Select Test Store success simulation; verify entitlement/UI |
+| Cancelled purchase | Pending manual verification | No Plus; non-fatal message; paywall remains usable |
+| Failed purchase | Pending manual verification | No Plus, crash, stale entitlement, or raw payload |
+| Restore/no restore | Pending manual verification | Verify active and inactive results for the same Clerk user |
+| Restart persistence | Pending manual verification | Kill/restart after verified purchase |
+| Renewal/expiration | Pending manual verification | Test Store accelerated lifecycle plus outbound refresh |
+| User A Plus / User B Free | Pending manual verification | User B must never inherit User A state |
+| Return from User B to User A | Pending manual verification | User A entitlement returns only after synchronization |
+| Account deletion | Pending manual verification | Must remain available regardless of subscription |
+| Offline behavior | Pending manual verification | Free journaling remains available; no unverified Plus grant |
+| Logcat privacy | Pending manual verification | Capture only the app process and scan per privacy audit |
+| Google Play Billing in sideload | Not applicable | Requires Google Play Internal Testing install |
+
+## Remaining configuration actions
+
+RevenueCat MCP verified the project, apps/providers, products, prices, entitlement attachments, offering/package relationships, current offering, and redacted Test Store public-key presence. It does not expose an operation to read or manage Sandbox Testing Access. Manually check that dashboard setting for the disposable Clerk test accounts; if an allowlist is required, use their stable Clerk `userId` values as RevenueCat App User IDs. This is the only manual RevenueCat dashboard action remaining.
+
+The existing EAS `development` environment contains `EXPO_PUBLIC_REVENUECAT_TEST_API_KEY`. Development explicitly selects it through `EXPO_PUBLIC_REVENUECAT_MODE=test-store`. Preview and Production explicitly select their Android public SDK key through `google-play` mode.
+
+## Verdict
+
+**REVENUECAT TEST STORE DEVELOPMENT PROFILE READY**
+
+This is not a runtime-passed verdict. Build only with:
+
+```bash
+eas build --platform android --profile development
+npx expo start --dev-client --clear
+```
+
+Treat the artifact as **INTERNAL TEST STORE BUILD — NEVER SUBMIT TO GOOGLE PLAY**.
diff --git a/docs/revenuecat-test-store-setup.md b/docs/revenuecat-test-store-setup.md
new file mode 100644
index 0000000..5a50d39
--- /dev/null
+++ b/docs/revenuecat-test-store-setup.md
@@ -0,0 +1,188 @@
+# RevenueCat Test Store setup
+
+Audit date: 2026-07-22
+
+Code candidate: source version 1.0.9, Android version code 9
+
+Profile status: **REVENUECAT TEST STORE DEVELOPMENT PROFILE READY**
+
+No Test Store APK was built or installed during this preparation. The live RevenueCat project was reconciled through RevenueCat MCP. Its existing Test Store configuration already matched the application contract, so the idempotent result was zero dashboard writes.
+
+## RevenueCat MCP reconciliation
+
+Connection and project selection:
+
+- RevenueCat MCP: connected
+- project: `DearDiary` (`proj11badf66`)
+- Android app/provider: `DearDiary Android` (`app43d78856cd`), Play Store package `com.aryan.deardiary`
+- Test Store app/provider: `Test Store` (`app4bec91040f`)
+- Test Store public key present: yes
+- key category: Test Store public SDK key
+- project/app association: verified
+
+The key value was not printed, stored, logged, or documented.
+
+Pre-change reads found the complete intended configuration already in place. The MCP operations performed were project/app listing, product listing and resource reads, Test Store price reads, entitlement and entitlement-product reads, offering/package/product relationship reads, current-offering verification, and redacted public-key metadata checks for the Test Store and Google Play apps. Fresh resource reads were then used for the final verification. No create, update, attach, detach, archive, or delete operation was needed.
+
+| Resource | Live identifier | Verified relationship |
+|---|---|---|
+| Test Store monthly product | `proda9ff3d9aa1` / `deardiary_pro_monthly` | Active `P1M`; $4.99 USD; entitlement and monthly package attached |
+| Test Store annual product | `prod1c44bca13c` / `deardiary_pro_yearly` | Active `P1Y`; $39.99 USD; entitlement and annual package attached |
+| Entitlement | `entleefa70a2be` / `DearDiary Pro` | Both Test Store and both Google Play products attached |
+| Current offering | `ofrng0ecaeb85d3` / `default` | Active and current |
+| Monthly package | `pkge73858b426a` / `$rc_monthly` | Test Store monthly plus Google Play monthly preserved |
+| Annual package | `pkgef61fa3ca89` / `$rc_annual` | Test Store annual plus Google Play annual preserved |
+| Google Play monthly | `prod98665bc767` / `deardiary_pro_monthly:monthly` | Active and unchanged |
+| Google Play annual | `prod3d5ea64d72` / `deardiary_pro_yearly:yearly` | Active and unchanged |
+
+Two inactive Test Store defaults (`monthly`, `yearly`) also exist. They were auto-created/legacy resources, are not attached to the intended entitlement or packages, and were left untouched. There is exactly one active intended Test Store monthly/annual pair and exactly one Test Store app.
+
+### Final redacted verification matrix
+
+| Resource | Expected | Actual | Status |
+|---|---|---|---|
+| Project | DearDiary | `DearDiary` (`proj11badf66`) | Passed |
+| Android app | `com.aryan.deardiary` | `DearDiary Android` (`app43d78856cd`), Play Store | Passed |
+| Test Store | Present | `Test Store` (`app4bec91040f`) | Passed |
+| Test Store public key | Present, redacted | Present; value withheld | Passed |
+| Entitlement | `DearDiary Pro` | `DearDiary Pro` (`entleefa70a2be`) | Passed |
+| Default offering | Current | `default` (`ofrng0ecaeb85d3`), current | Passed |
+| Monthly Test product | Present | `proda9ff3d9aa1`, `P1M`, active | Passed |
+| Annual Test product | Present | `prod1c44bca13c`, `P1Y`, active | Passed |
+| Monthly entitlement attachment | `DearDiary Pro` | Attached; Google Play monthly also attached | Passed |
+| Annual entitlement attachment | `DearDiary Pro` | Attached; Google Play annual also attached | Passed |
+| Monthly package | Test + Google products preserved | `$rc_monthly` contains both intended products | Passed |
+| Annual package | Test + Google products preserved | `$rc_annual` contains both intended products | Passed |
+| Sandbox Testing Access | Allows intended internal test | State is not exposed by RevenueCat MCP | Manual verification required |
+
+### Unsupported RevenueCat MCP operation
+
+The connected RevenueCat MCP exposes no read or write operation for Sandbox Testing Access. This is the only remaining manual RevenueCat dashboard action: inspect Sandbox Testing Access and grant the minimum sandbox-only access needed for the disposable test accounts. If the dashboard mode requires an allowlist, enter the stable disposable Clerk `userId` values—the same values the app sends as RevenueCat App User IDs. Do not use email addresses or fabricate identifiers. Production entitlement behavior must remain unchanged.
+
+## Build isolation
+
+| EAS profile | EAS environment | RevenueCat mode | Selected key | Android artifact | Distribution |
+|---|---|---|---|---|---|
+| `development` | `development` | `test-store` | `EXPO_PUBLIC_REVENUECAT_TEST_API_KEY` | Debug development-client APK | Internal |
+| `preview` | `preview` | `google-play` | `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY` | APK | Internal |
+| `production` | `production` | `google-play` | `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY` | AAB | Store |
+
+Development is the only Test Store path. It uses the existing Development EAS environment and produces a debuggable Expo development client with the generated dev-client scheme enabled. Preview and Production remain Google Play release builds with that generated scheme disabled.
+
+The matrix describes the requested Android builds. Existing iOS EAS builds continue to select `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` when `EAS_BUILD_PLATFORM=ios`; an Android build never places that iOS key in `extra.revenueCat`.
+
+`app.config.js` resolves exactly one client-safe RevenueCat public SDK key from the explicit mode and places only `{ mode, apiKey }` in Expo runtime `extra`. Development, Preview, and Production fail configuration when their selected key is absent. Development requires Test Store mode; Preview and Production require Google Play mode. Production also rejects a Test Store-looking selected key and rejects the presence of `EXPO_PUBLIC_REVENUECAT_TEST_API_KEY` in the Production environment. Errors never contain key values.
+
+## Required EAS environment variables
+
+Set these in Expo project settings under **Environment variables**. Client-side `EXPO_PUBLIC_` variables must be readable while resolving and bundling the app, so use `sensitive` or `plaintext` visibility, not `secret` visibility.
+
+Development environment:
+
+- `EXPO_PUBLIC_REVENUECAT_TEST_API_KEY` (already configured in EAS)
+
+Preview environment:
+
+- `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY`
+
+Production environment:
+
+- `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY`
+- no `EXPO_PUBLIC_REVENUECAT_TEST_API_KEY`
+
+Do not put values in `eas.json`, source files, documentation, terminal history, screenshots, or logs. The Test Store key is a public SDK key, not a RevenueCat REST secret.
+
+The safer setup path is the EAS dashboard because it avoids placing a value in shell history. If the Development Test Store key ever needs to be recreated, an interactive CLI alternative that prompts for the value is:
+
+```bash
+eas env:create --environment development --name EXPO_PUBLIC_REVENUECAT_TEST_API_KEY --visibility sensitive
+```
+
+Do not configure any of these in the mobile app:
+
+- `REVENUECAT_SECRET_KEY`
+- a RevenueCat REST API secret (`sk_…`)
+- webhook authorization secrets
+- Google service-account credentials
+
+## Verified RevenueCat dashboard state
+
+The Test Store app, redacted public-key presence, active monthly/yearly products, entitlement attachments, predefined package relationships, preserved Google Play relationships, and current offering were all verified through fresh RevenueCat MCP reads. The app selects RevenueCat offering package slots/types and localized product metadata, so no Test Store product identifier needs to be hardcoded in the mobile UI.
+
+## SDK compatibility
+
+Installed package:
+
+- `react-native-purchases@10.4.2`
+- `react-native-purchases-ui` is not installed or used
+
+RevenueCat currently documents React Native 9.5.4 as the Test Store minimum. The installed 10.4.2 package is compatible, so no dependency file or native dependency changed. A fresh native APK is still required because the existing APK was built with the Google Play configuration. If RevenueCat native dependencies change later, create a fresh APK and complete a new MobSF scan before broader distribution.
+
+References:
+
+- [RevenueCat Test Store](https://www.revenuecat.com/docs/test-and-launch/sandbox/test-store)
+- [RevenueCat customer identity](https://www.revenuecat.com/docs/customers/identifying-customers)
+- [RevenueCat CustomerInfo](https://www.revenuecat.com/docs/customers/customer-info)
+- [Expo EAS environment variables](https://docs.expo.dev/eas/environment-variables/manage/)
+
+## Pre-build verification
+
+Run:
+
+```bash
+npm run verify:revenuecat-config
+npm run test:revenuecat-config
+npm run test:revenuecat-runtime
+npx expo-doctor
+npx tsc --noEmit
+npm run lint
+```
+
+The config verification reports only the profile, mode, key presence/category, development-client flag, distribution, build type, and EAS environment. It uses non-secret placeholders and never prints configured EAS values.
+
+## Build command
+
+After Sandbox Testing Access is checked, build the existing Development profile:
+
+```bash
+eas build --platform android --profile development
+npx expo start --dev-client --clear
+```
+
+Label and handle the artifact as:
+
+> INTERNAL TEST STORE BUILD — NEVER SUBMIT TO GOOGLE PLAY
+
+Do not submit it, promote it to a public link, reuse it as a Preview/Production candidate, or treat Expo Go as purchase proof.
+
+## Post-build runtime matrix
+
+Use disposable Clerk users and a dedicated device/profile. Do not use a real charge.
+
+| Check | Expected result |
+|---|---|
+| Monthly package | RevenueCat monthly package displays Test Store localized metadata |
+| Yearly package | RevenueCat annual package displays Test Store localized metadata |
+| Success | Test Store success activates `DearDiary Pro` and updates the UI without restart |
+| Failure | No Plus access; safe retryable message; paywall remains usable |
+| Cancellation | No Plus access; cancellation is non-fatal; paywall remains usable |
+| Restore | Plus activates only when refreshed `CustomerInfo` has the entitlement |
+| No restore | Safe “No active subscription” result; no Plus access |
+| Restart | Verified entitlement reloads for the same Clerk user |
+| Renewal | Outbound RevenueCat refresh updates `CustomerInfo`; Plus remains active |
+| Expiration | `DearDiary Pro` becomes inactive; core journal data remains intact |
+| User A Plus → User B Free | User B never displays User A’s entitlement, even briefly |
+| User B → User A | User A’s own entitlement returns only after identity synchronization |
+| Account deletion | Remains available and clears the existing user-scoped data paths |
+| Offline | Last verified same-user state follows the existing in-memory policy; no unverified Plus grant; free journaling remains usable |
+| Expo Go | Layout/mock flow only; mock `CustomerInfo` never proves entitlement |
+| Logcat privacy | No API key, purchase token, CustomerInfo payload, entitlement payload, journal content, auth token, password, or PIN |
+
+RevenueCat Test Store should display simulation choices for successful purchase, failed purchase, and cancellation. Test renewals/expiration on RevenueCat’s accelerated Test Store schedule and trigger outbound SDK work (foreground/refresh) because CustomerInfo listener updates are not server pushes.
+
+## Remaining boundaries
+
+- Mobile `CustomerInfo` controls immediate UI access only after Clerk/RevenueCat identity synchronization.
+- Server-side AI quotas continue to use the existing Supabase Edge Function subscription checks and usage ledger; this change does not weaken them or grant server access from a local toggle.
+- Journal CRUD, App Lock, biometrics, account deletion, navigation guards, notifications, and Supabase sync remain outside the subscription gate.
+- Google Play Billing still requires an authorized Google Play test install. A sideloaded Google Play-key APK cannot validate Play products.
diff --git a/eas.json b/eas.json
index d6857fb..546576e 100644
--- a/eas.json
+++ b/eas.json
@@ -6,18 +6,27 @@
"development": {
"developmentClient": true,
"distribution": "internal",
- "environment": "development"
+ "environment": "development",
+ "env": {
+ "EXPO_PUBLIC_REVENUECAT_MODE": "test-store"
+ }
},
"preview": {
"developmentClient": false,
"distribution": "internal",
"environment": "preview",
+ "env": {
+ "EXPO_PUBLIC_REVENUECAT_MODE": "google-play"
+ },
"android": {
"buildType": "apk"
}
},
"production": {
"environment": "production",
+ "env": {
+ "EXPO_PUBLIC_REVENUECAT_MODE": "google-play"
+ },
"android": {
"buildType": "app-bundle"
}
diff --git a/lib/environment.ts b/lib/environment.ts
index 585ff8d..eae99c0 100644
--- a/lib/environment.ts
+++ b/lib/environment.ts
@@ -4,8 +4,6 @@ export type PublicEnvironment = {
accountDeletionUrl: string | null;
appEnvironment: AppEnvironment;
clerkPublishableKey: string;
- revenueCatAndroidApiKey: string | null;
- revenueCatIosApiKey: string | null;
supabasePublicKey: string;
supabaseUrl: string;
};
@@ -14,8 +12,6 @@ export type PublicEnvironmentInput = {
accountDeletionUrl?: string;
appEnvironment?: string;
clerkPublishableKey?: string;
- revenueCatAndroidApiKey?: string;
- revenueCatIosApiKey?: string;
supabasePublicKey?: string;
supabaseUrl?: string;
};
@@ -34,9 +30,6 @@ 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,
});
@@ -54,8 +47,6 @@ 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);
@@ -119,8 +110,6 @@ export function validatePublicEnvironment(
accountDeletionUrl: rawAccountDeletionUrl || null,
appEnvironment,
clerkPublishableKey: rawClerkPublishableKey,
- revenueCatAndroidApiKey: rawRevenueCatAndroidApiKey || null,
- revenueCatIosApiKey: rawRevenueCatIosApiKey || null,
supabasePublicKey: rawSupabasePublicKey,
supabaseUrl: rawSupabaseUrl,
},
diff --git a/lib/subscription/requestDeduper.ts b/lib/subscription/requestDeduper.ts
new file mode 100644
index 0000000..e5cb0d7
--- /dev/null
+++ b/lib/subscription/requestDeduper.ts
@@ -0,0 +1,22 @@
+export function createKeyedRequestDeduper() {
+ const activeRequests = new Map>();
+
+ return {
+ run(key: string, request: () => Promise) {
+ const activeRequest = activeRequests.get(key);
+
+ if (activeRequest) {
+ return activeRequest;
+ }
+
+ const nextRequest = request().finally(() => {
+ if (activeRequests.get(key) === nextRequest) {
+ activeRequests.delete(key);
+ }
+ });
+
+ activeRequests.set(key, nextRequest);
+ return nextRequest;
+ },
+ };
+}
diff --git a/lib/subscription/revenueCat.ts b/lib/subscription/revenueCat.ts
index a4c327f..fbbeb06 100644
--- a/lib/subscription/revenueCat.ts
+++ b/lib/subscription/revenueCat.ts
@@ -1,16 +1,31 @@
+import Constants from "expo-constants";
import Purchases, {
LOG_LEVEL,
PURCHASES_ERROR_CODE,
type CustomerInfo,
type PurchasesError,
+ type PurchasesOfferings,
} from "react-native-purchases";
-import { getPublicEnvironment } from "@/lib/environment";
+import { createKeyedRequestDeduper } from "@/lib/subscription/requestDeduper";
import { revenueCatEntitlementId } from "@/lib/subscription/constants";
+import { createRunOnce } from "@/lib/subscription/runOnce";
+import {
+ hasVerifiedEntitlement,
+ isEntitlementEnvironmentAuthoritative,
+} from "@/lib/subscription/subscriptionState";
+export type RevenueCatMode = "google-play" | "test-store";
export type RevenueCatPlatform = "android" | "ios";
-let isRevenueCatConfigured = false;
+type RevenueCatRuntimeConfig = {
+ apiKey: string | null;
+ mode: RevenueCatMode;
+};
+
+const configureOnce = createRunOnce();
+const customerInfoRequests = createKeyedRequestDeduper();
+const offeringRequests = createKeyedRequestDeduper();
export function getRevenueCatPlatform(): RevenueCatPlatform | null {
if (process.env.EXPO_OS === "android" || process.env.EXPO_OS === "ios") {
@@ -20,21 +35,51 @@ export function getRevenueCatPlatform(): RevenueCatPlatform | null {
return null;
}
-export function getRevenueCatApiKey() {
- const environment = getPublicEnvironment();
- const platform = getRevenueCatPlatform();
+export function getRevenueCatRuntimeConfig() {
+ const candidate: unknown = Constants.expoConfig?.extra?.revenueCat;
+
+ if (!isRecord(candidate) || !isRevenueCatMode(candidate.mode)) {
+ return null;
+ }
+
+ if (candidate.apiKey !== null && typeof candidate.apiKey !== "string") {
+ return null;
+ }
+
+ const apiKey = candidate.apiKey?.trim() || null;
- if (!environment || !platform) {
+ return {
+ apiKey,
+ mode: candidate.mode,
+ } satisfies RevenueCatRuntimeConfig;
+}
+
+export function getRevenueCatApiKey() {
+ if (!getRevenueCatPlatform()) {
return null;
}
- return platform === "ios"
- ? environment.revenueCatIosApiKey
- : environment.revenueCatAndroidApiKey;
+ return getRevenueCatRuntimeConfig()?.apiKey ?? null;
}
-export function hasProEntitlement(customerInfo: CustomerInfo | null) {
- return Boolean(customerInfo?.entitlements.active[revenueCatEntitlementId]);
+export function getRevenueCatMode() {
+ return getRevenueCatRuntimeConfig()?.mode ?? null;
+}
+
+export function isRevenueCatEntitlementAuthoritative() {
+ return isEntitlementEnvironmentAuthoritative(Constants.executionEnvironment);
+}
+
+export function hasProEntitlement(
+ customerInfo: CustomerInfo | null,
+ identityMatches: boolean,
+) {
+ return hasVerifiedEntitlement({
+ customerInfo,
+ entitlementId: revenueCatEntitlementId,
+ identityMatches,
+ isAuthoritative: isRevenueCatEntitlementAuthoritative(),
+ });
}
export function isRevenueCatPurchaseCancelled(error: unknown) {
@@ -50,7 +95,7 @@ export function getFriendlyRevenueCatError(error: unknown) {
}
if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
- return "Purchase was cancelled.";
+ return "Purchase was cancelled. You can try again when you are ready.";
}
if (error.code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
@@ -79,13 +124,22 @@ export function getFriendlyRevenueCatError(error: unknown) {
}
export function configureRevenueCat(apiKey: string, appUserID?: string) {
- if (isRevenueCatConfigured) {
- return;
- }
+ return configureOnce.run(() => {
+ Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.WARN);
+ Purchases.configure(appUserID ? { apiKey, appUserID } : { apiKey });
+ });
+}
- Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.WARN);
- Purchases.configure(appUserID ? { apiKey, appUserID } : { apiKey });
- isRevenueCatConfigured = true;
+export function getRevenueCatCustomerInfo(identityKey: string) {
+ return customerInfoRequests.run(identityKey, () => Purchases.getCustomerInfo());
+}
+
+export function getRevenueCatOfferings(identityKey: string) {
+ return offeringRequests.run(identityKey, () => Purchases.getOfferings());
+}
+
+function isRevenueCatMode(value: unknown): value is RevenueCatMode {
+ return value === "google-play" || value === "test-store";
}
function isPurchasesError(error: unknown): error is PurchasesError {
@@ -96,3 +150,7 @@ function isPurchasesError(error: unknown): error is PurchasesError {
typeof (error as { code: unknown }).code === "string"
);
}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/lib/subscription/revenueCatBuildConfig.js b/lib/subscription/revenueCatBuildConfig.js
new file mode 100644
index 0000000..6415fd8
--- /dev/null
+++ b/lib/subscription/revenueCatBuildConfig.js
@@ -0,0 +1,155 @@
+const REVENUECAT_MODES = new Set(["google-play", "test-store"]);
+const REQUIRED_MODES_BY_PROFILE = {
+ development: "test-store",
+ preview: "google-play",
+ production: "google-play",
+};
+
+function resolveRevenueCatBuildConfig({
+ androidApiKey,
+ appEnvironment,
+ buildPlatform,
+ buildProfile,
+ iosApiKey,
+ mode,
+ testApiKey,
+}) {
+ const normalizedProfile = normalizeOptionalValue(buildProfile);
+ const normalizedAppEnvironment = normalizeOptionalValue(appEnvironment);
+ const normalizedMode = normalizeOptionalValue(mode) || "google-play";
+ const normalizedAndroidApiKey = normalizeOptionalValue(androidApiKey);
+ const normalizedIosApiKey = normalizeOptionalValue(iosApiKey);
+ const normalizedTestApiKey = normalizeOptionalValue(testApiKey);
+ const normalizedBuildPlatform = normalizeOptionalValue(buildPlatform);
+ const requiredProfileMode = REQUIRED_MODES_BY_PROFILE[normalizedProfile];
+ const isProduction =
+ normalizedProfile === "production" ||
+ normalizedAppEnvironment === "production";
+
+ if (!REVENUECAT_MODES.has(normalizedMode)) {
+ throw new Error(
+ "EXPO_PUBLIC_REVENUECAT_MODE must be google-play or test-store.",
+ );
+ }
+
+ if (requiredProfileMode && normalizedMode !== requiredProfileMode) {
+ throw new Error(
+ `${normalizedProfile} builds must use RevenueCat ${requiredProfileMode} mode.`,
+ );
+ }
+
+ if (isProduction && normalizedMode !== "google-play") {
+ throw new Error("Production cannot use RevenueCat Test Store mode.");
+ }
+
+ if (isProduction && normalizedTestApiKey) {
+ throw new Error(
+ "Production must not define a RevenueCat Test Store public SDK key.",
+ );
+ }
+
+ const selectedApiKey =
+ normalizedMode === "test-store"
+ ? normalizedTestApiKey
+ : normalizedBuildPlatform === "ios"
+ ? normalizedIosApiKey
+ : normalizedAndroidApiKey;
+ const selectedKeyCategory = getRevenueCatKeyCategory(selectedApiKey);
+ const expectedPlatformKeyCategory =
+ normalizedBuildPlatform === "ios" ? "app-store" : "google-play";
+
+ if (
+ requiredProfileMode &&
+ !selectedApiKey
+ ) {
+ throw new Error(
+ `RevenueCat ${normalizedMode} public SDK key is required for ${normalizedProfile} builds.`,
+ );
+ }
+
+ if (
+ normalizedMode === "google-play" &&
+ selectedKeyCategory !== "missing" &&
+ selectedKeyCategory !== "unknown" &&
+ selectedKeyCategory !== expectedPlatformKeyCategory
+ ) {
+ throw new Error(
+ `RevenueCat google-play mode cannot select ${getKeyCategoryLabel(selectedKeyCategory)} public SDK key for this platform.`,
+ );
+ }
+
+ if (
+ normalizedMode === "test-store" &&
+ (selectedKeyCategory === "google-play" ||
+ selectedKeyCategory === "app-store")
+ ) {
+ throw new Error(
+ "RevenueCat test-store mode cannot select a platform Store public SDK key.",
+ );
+ }
+
+ return {
+ apiKey: selectedApiKey,
+ mode: normalizedMode,
+ };
+}
+
+function resolveRevenueCatBuildConfigFromEnvironment(environment) {
+ return resolveRevenueCatBuildConfig({
+ androidApiKey: environment.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY,
+ appEnvironment: environment.EXPO_PUBLIC_APP_ENV,
+ buildPlatform: environment.EAS_BUILD_PLATFORM || environment.EXPO_OS,
+ buildProfile: environment.EAS_BUILD_PROFILE,
+ iosApiKey: environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY,
+ mode: environment.EXPO_PUBLIC_REVENUECAT_MODE,
+ testApiKey: environment.EXPO_PUBLIC_REVENUECAT_TEST_API_KEY,
+ });
+}
+
+function getRevenueCatKeyCategory(apiKey) {
+ const normalizedApiKey = normalizeOptionalValue(apiKey);
+
+ if (!normalizedApiKey) {
+ return "missing";
+ }
+
+ if (normalizedApiKey.startsWith("test_")) {
+ return "test-store";
+ }
+
+ if (normalizedApiKey.startsWith("goog_")) {
+ return "google-play";
+ }
+
+ if (normalizedApiKey.startsWith("appl_")) {
+ return "app-store";
+ }
+
+ return "unknown";
+}
+
+function normalizeOptionalValue(value) {
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return value.trim() || null;
+}
+
+function getKeyCategoryLabel(category) {
+ if (category === "test-store") {
+ return "a Test Store";
+ }
+
+ if (category === "app-store") {
+ return "an App Store";
+ }
+
+ return "a Google Play";
+}
+
+module.exports = {
+ getRevenueCatKeyCategory,
+ resolveRevenueCatBuildConfig,
+ resolveRevenueCatBuildConfigFromEnvironment,
+};
diff --git a/lib/subscription/revenueCatIdentity.ts b/lib/subscription/revenueCatIdentity.ts
new file mode 100644
index 0000000..eada54e
--- /dev/null
+++ b/lib/subscription/revenueCatIdentity.ts
@@ -0,0 +1,52 @@
+export type RevenueCatIdentitySdk = {
+ getAppUserID: () => Promise;
+ getCustomerInfo: () => Promise;
+ isAnonymous: () => Promise;
+ logIn: (appUserId: string) => Promise;
+ logOut: () => Promise;
+};
+
+export type RevenueCatIdentityResult =
+ | {
+ appUserId: string;
+ customerInfo: CustomerInfo;
+ }
+ | {
+ appUserId: null;
+ customerInfo: null;
+ };
+
+export async function synchronizeRevenueCatIdentity(
+ sdk: RevenueCatIdentitySdk,
+ clerkUserId: string | null,
+): Promise> {
+ if (!clerkUserId) {
+ const isAnonymous = await sdk.isAnonymous();
+
+ if (!isAnonymous) {
+ await sdk.logOut();
+ }
+
+ return {
+ appUserId: null,
+ customerInfo: null,
+ };
+ }
+
+ const currentAppUserId = await sdk.getAppUserID();
+
+ if (currentAppUserId !== clerkUserId) {
+ await sdk.logIn(clerkUserId);
+ }
+
+ const synchronizedAppUserId = await sdk.getAppUserID();
+
+ if (synchronizedAppUserId !== clerkUserId) {
+ throw new Error("RevenueCat identity synchronization did not complete.");
+ }
+
+ return {
+ appUserId: clerkUserId,
+ customerInfo: await sdk.getCustomerInfo(),
+ };
+}
diff --git a/lib/subscription/runOnce.ts b/lib/subscription/runOnce.ts
new file mode 100644
index 0000000..dbb5973
--- /dev/null
+++ b/lib/subscription/runOnce.ts
@@ -0,0 +1,15 @@
+export function createRunOnce() {
+ let hasRun = false;
+
+ return {
+ run(action: () => void) {
+ if (hasRun) {
+ return false;
+ }
+
+ action();
+ hasRun = true;
+ return true;
+ },
+ };
+}
diff --git a/lib/subscription/subscriptionState.ts b/lib/subscription/subscriptionState.ts
new file mode 100644
index 0000000..9658e93
--- /dev/null
+++ b/lib/subscription/subscriptionState.ts
@@ -0,0 +1,36 @@
+export type CustomerInfoWithEntitlements = {
+ entitlements: {
+ active: Record;
+ };
+};
+
+export function hasVerifiedEntitlement(params: {
+ customerInfo: CustomerInfoWithEntitlements | null;
+ entitlementId: string;
+ identityMatches: boolean;
+ isAuthoritative: boolean;
+}) {
+ return Boolean(
+ params.isAuthoritative &&
+ params.identityMatches &&
+ params.customerInfo?.entitlements.active[params.entitlementId],
+ );
+}
+
+export function isEntitlementEnvironmentAuthoritative(
+ executionEnvironment: string,
+) {
+ return executionEnvironment !== "storeClient";
+}
+
+export function isRevenueCatIdentityCurrent(params: {
+ activeUserId: string | null;
+ revenueCatUserId: string | null;
+ synchronizedUserId: string | null;
+}) {
+ return Boolean(
+ params.activeUserId &&
+ params.activeUserId === params.revenueCatUserId &&
+ params.activeUserId === params.synchronizedUserId,
+ );
+}
diff --git a/package.json b/package.json
index 9c11957..a1d2890 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,9 @@
"test:entry-reflection-theme-tags": "tsc tests/entry-reflection-theme-tags.test.ts lib/entryReflectionThemeTags.ts lib/tags.ts --outDir /tmp/dear-diary-entry-reflection-theme-tags-test --module commonjs --target es2022 --esModuleInterop --skipLibCheck && node /tmp/dear-diary-entry-reflection-theme-tags-test/tests/entry-reflection-theme-tags.test.js",
"test:navigation-transitions": "node --no-warnings tests/navigation-transition-map.test.ts",
"test:route-security": "node --no-warnings tests/route-security.test.mjs",
+ "test:revenuecat-config": "node tests/revenuecat-build-config.test.mjs",
+ "test:revenuecat-runtime": "node --no-warnings tests/revenuecat-runtime.test.ts",
+ "verify:revenuecat-config": "node scripts/verify-revenuecat-config.mjs",
"web": "expo start --web",
"lint": "expo lint"
},
diff --git a/providers/SubscriptionProvider.tsx b/providers/SubscriptionProvider.tsx
index 9491774..e92067b 100644
--- a/providers/SubscriptionProvider.tsx
+++ b/providers/SubscriptionProvider.tsx
@@ -13,51 +13,51 @@ import {
configureRevenueCat,
getFriendlyRevenueCatError,
getRevenueCatApiKey,
+ getRevenueCatCustomerInfo,
+ getRevenueCatMode,
+ getRevenueCatOfferings,
hasProEntitlement,
} from "@/lib/subscription/revenueCat";
+import { synchronizeRevenueCatIdentity } from "@/lib/subscription/revenueCatIdentity";
+import { isRevenueCatIdentityCurrent } from "@/lib/subscription/subscriptionState";
+
+const subscriptionUnavailableMessage =
+ "Subscriptions are unavailable right now.";
export function SubscriptionProvider({ children }: { children: ReactNode }) {
const { isLoaded, userId } = useAuth();
+ const clerkUserId = userId ?? null;
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 activeUserIdRef = useRef(clerkUserId);
+ const synchronizedUserIdRef = useRef(null);
+ const identityVersionRef = useRef(0);
const apiKey = getRevenueCatApiKey();
+ const revenueCatMode = getRevenueCatMode();
- const resetCustomerState = useCallback(() => {
- activeUserIdRef.current = null;
- setCustomerInfo(null);
- setOfferings(null);
- setError(null);
- }, []);
-
- const loadRevenueCatState = useCallback(async () => {
- if (!configuredRef.current) {
- return;
- }
+ const getIdentityKey = useCallback(
+ (identityUserId: string | null) =>
+ `${revenueCatMode ?? "unavailable"}:${identityUserId ?? "anonymous"}`,
+ [revenueCatMode],
+ );
- setIsLoading(true);
- setError(null);
+ useEffect(() => {
+ activeUserIdRef.current = clerkUserId;
+ }, [clerkUserId]);
- try {
- const [latestCustomerInfo, latestOfferings] = await Promise.all([
- Purchases.getCustomerInfo(),
- Purchases.getOfferings(),
- ]);
+ useEffect(() => {
+ const identityVersion = identityVersionRef.current + 1;
- setCustomerInfo(latestCustomerInfo);
- setOfferings(latestOfferings);
- } catch {
- setError("Subscriptions are unavailable right now.");
- } finally {
- setIsLoading(false);
- }
- }, []);
+ identityVersionRef.current = identityVersion;
+ synchronizedUserIdRef.current = null;
+ setCustomerInfo(null);
+ setOfferings(null);
+ setError(null);
- useEffect(() => {
if (!isLoaded) {
setIsLoading(true);
return;
@@ -67,69 +67,94 @@ export function SubscriptionProvider({ children }: { children: ReactNode }) {
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;
+ try {
+ if (!configuredRef.current) {
+ configureRevenueCat(apiKey, clerkUserId ?? undefined);
+ configuredRef.current = true;
+ }
+
setIsConfigured(true);
+ } catch {
+ configuredRef.current = false;
+ setIsConfigured(false);
+ setIsLoading(false);
+ setError("Subscriptions could not be initialized for this build.");
+ return;
}
let isActive = true;
+ const identityKey = getIdentityKey(clerkUserId);
- async function identifyCustomer() {
+ async function synchronizeIdentity() {
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);
+ const identityResult = await synchronizeRevenueCatIdentity(
+ {
+ getAppUserID: () => Purchases.getAppUserID(),
+ getCustomerInfo: () => getRevenueCatCustomerInfo(identityKey),
+ isAnonymous: () => Purchases.isAnonymous(),
+ logIn: (nextUserId) => Purchases.logIn(nextUserId),
+ logOut: () => Purchases.logOut(),
+ },
+ clerkUserId,
+ );
+
+ if (!isCurrentIdentity()) {
+ return;
+ }
- if (!isActive) {
- return;
- }
+ synchronizedUserIdRef.current = identityResult.appUserId;
+ setCustomerInfo(identityResult.customerInfo);
- setCustomerInfo(null);
+ if (!identityResult.appUserId) {
+ return;
}
- const latestOfferings = await Purchases.getOfferings();
+ try {
+ const latestOfferings = await getRevenueCatOfferings(identityKey);
- if (isActive) {
- setOfferings(latestOfferings);
+ if (isCurrentIdentity()) {
+ setOfferings(latestOfferings);
+ }
+ } catch {
+ if (isCurrentIdentity()) {
+ setOfferings(null);
+ setError(subscriptionUnavailableMessage);
+ }
}
} catch {
- if (isActive) {
- setError("Subscriptions are unavailable right now.");
- setOfferings(null);
+ if (isCurrentIdentity()) {
+ synchronizedUserIdRef.current = null;
setCustomerInfo(null);
+ setOfferings(null);
+ setError(subscriptionUnavailableMessage);
}
} finally {
- if (isActive) {
+ if (isCurrentIdentity()) {
setIsLoading(false);
}
}
}
- void identifyCustomer();
+ function isCurrentIdentity() {
+ return (
+ isActive &&
+ identityVersionRef.current === identityVersion &&
+ activeUserIdRef.current === clerkUserId
+ );
+ }
+
+ void synchronizeIdentity();
return () => {
isActive = false;
};
- }, [apiKey, isLoaded, resetCustomerState, userId]);
+ }, [apiKey, clerkUserId, getIdentityKey, isLoaded]);
useEffect(() => {
if (!isConfigured) {
@@ -137,7 +162,28 @@ export function SubscriptionProvider({ children }: { children: ReactNode }) {
}
const listener: CustomerInfoUpdateListener = (nextCustomerInfo) => {
- setCustomerInfo(nextCustomerInfo);
+ const expectedUserId = activeUserIdRef.current;
+
+ if (
+ !expectedUserId ||
+ synchronizedUserIdRef.current !== expectedUserId
+ ) {
+ return;
+ }
+
+ void Purchases.getAppUserID()
+ .then((revenueCatUserId) => {
+ if (
+ isRevenueCatIdentityCurrent({
+ activeUserId: activeUserIdRef.current,
+ revenueCatUserId,
+ synchronizedUserId: synchronizedUserIdRef.current,
+ })
+ ) {
+ setCustomerInfo(nextCustomerInfo);
+ }
+ })
+ .catch(() => undefined);
};
Purchases.addCustomerInfoUpdateListener(listener);
@@ -148,67 +194,155 @@ export function SubscriptionProvider({ children }: { children: ReactNode }) {
}, [isConfigured]);
const refresh = useCallback(async () => {
- await loadRevenueCatState();
- }, [loadRevenueCatState]);
+ const expectedUserId = activeUserIdRef.current;
+
+ if (
+ !configuredRef.current ||
+ !expectedUserId ||
+ synchronizedUserIdRef.current !== expectedUserId
+ ) {
+ return;
+ }
+
+ const identityKey = getIdentityKey(expectedUserId);
+
+ setIsLoading(true);
+ setError(null);
+
+ const [customerInfoResult, offeringsResult] = await Promise.allSettled([
+ getRevenueCatCustomerInfo(identityKey),
+ getRevenueCatOfferings(identityKey),
+ ]);
+
+ if (
+ activeUserIdRef.current !== expectedUserId ||
+ synchronizedUserIdRef.current !== expectedUserId
+ ) {
+ return;
+ }
+
+ if (customerInfoResult.status === "fulfilled") {
+ setCustomerInfo(customerInfoResult.value);
+ }
+
+ if (offeringsResult.status === "fulfilled") {
+ setOfferings(offeringsResult.value);
+ }
+
+ if (
+ customerInfoResult.status === "rejected" ||
+ offeringsResult.status === "rejected"
+ ) {
+ setError(subscriptionUnavailableMessage);
+ }
+
+ setIsLoading(false);
+ }, [getIdentityKey]);
const purchasePackage = useCallback(
async (selectedPackage: PurchasesPackage) => {
- if (!configuredRef.current) {
- throw new Error("Subscriptions are not configured for this build.");
- }
+ const expectedUserId = requireSynchronizedUser(
+ configuredRef.current,
+ activeUserIdRef.current,
+ synchronizedUserIdRef.current,
+ );
setIsLoading(true);
setError(null);
try {
- const result = await Purchases.purchasePackage(selectedPackage);
-
- setCustomerInfo(result.customerInfo);
- await loadRevenueCatState();
- return result.customerInfo;
+ await Purchases.purchasePackage(selectedPackage);
+ return await refreshCustomerForUser(
+ expectedUserId,
+ getIdentityKey(expectedUserId),
+ );
} catch (purchaseError) {
- const message = getFriendlyRevenueCatError(purchaseError);
+ if (purchaseError instanceof SubscriptionIdentityError) {
+ throw purchaseError;
+ }
- setError(message);
- throw new Error(message);
+ throw new Error(getFriendlyRevenueCatError(purchaseError));
} finally {
- setIsLoading(false);
+ if (activeUserIdRef.current === expectedUserId) {
+ setIsLoading(false);
+ }
}
},
- [loadRevenueCatState],
+ [getIdentityKey],
);
const restorePurchases = useCallback(async () => {
- if (!configuredRef.current) {
- throw new Error("Subscriptions are not configured for this build.");
- }
+ const expectedUserId = requireSynchronizedUser(
+ configuredRef.current,
+ activeUserIdRef.current,
+ synchronizedUserIdRef.current,
+ );
setIsLoading(true);
setError(null);
try {
- const restoredCustomerInfo = await Purchases.restorePurchases();
-
- setCustomerInfo(restoredCustomerInfo);
- await loadRevenueCatState();
- return restoredCustomerInfo;
+ await Purchases.restorePurchases();
+ return await refreshCustomerForUser(
+ expectedUserId,
+ getIdentityKey(expectedUserId),
+ );
} catch (restoreError) {
- const message = getFriendlyRevenueCatError(restoreError);
+ if (restoreError instanceof SubscriptionIdentityError) {
+ throw restoreError;
+ }
- setError(message);
- throw new Error(message);
+ throw new Error(getFriendlyRevenueCatError(restoreError));
} finally {
- setIsLoading(false);
+ if (activeUserIdRef.current === expectedUserId) {
+ setIsLoading(false);
+ }
+ }
+ }, [getIdentityKey]);
+
+ async function refreshCustomerForUser(
+ expectedUserId: string,
+ identityKey: string,
+ ) {
+ const revenueCatUserId = await Purchases.getAppUserID();
+
+ if (
+ revenueCatUserId !== expectedUserId ||
+ activeUserIdRef.current !== expectedUserId ||
+ synchronizedUserIdRef.current !== expectedUserId
+ ) {
+ throw new SubscriptionIdentityError(
+ "Your account changed before the subscription update completed. Please try again.",
+ );
+ }
+
+ const latestCustomerInfo = await getRevenueCatCustomerInfo(identityKey);
+
+ if (
+ activeUserIdRef.current !== expectedUserId ||
+ synchronizedUserIdRef.current !== expectedUserId
+ ) {
+ throw new SubscriptionIdentityError(
+ "Your account changed before the subscription update completed. Please try again.",
+ );
}
- }, [loadRevenueCatState]);
+ setCustomerInfo(latestCustomerInfo);
+ return latestCustomerInfo;
+ }
+
+ const identityMatches = isRevenueCatIdentityCurrent({
+ activeUserId: clerkUserId,
+ revenueCatUserId: clerkUserId,
+ synchronizedUserId: synchronizedUserIdRef.current,
+ });
const value = useMemo(
() => ({
customerInfo,
error,
isConfigured,
isLoading,
- isPro: hasProEntitlement(customerInfo),
+ isPro: hasProEntitlement(customerInfo, identityMatches),
offerings,
purchasePackage,
refresh,
@@ -217,6 +351,7 @@ export function SubscriptionProvider({ children }: { children: ReactNode }) {
[
customerInfo,
error,
+ identityMatches,
isConfigured,
isLoading,
offerings,
@@ -232,3 +367,28 @@ export function SubscriptionProvider({ children }: { children: ReactNode }) {
);
}
+
+class SubscriptionIdentityError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "SubscriptionIdentityError";
+ }
+}
+
+function requireSynchronizedUser(
+ isConfigured: boolean,
+ activeUserId: string | null,
+ synchronizedUserId: string | null,
+) {
+ if (!isConfigured) {
+ throw new Error("Subscriptions are not configured for this build.");
+ }
+
+ if (!activeUserId || synchronizedUserId !== activeUserId) {
+ throw new Error(
+ "Subscriptions are still synchronizing with your account. Please try again.",
+ );
+ }
+
+ return activeUserId;
+}
diff --git a/scripts/verify-revenuecat-config.mjs b/scripts/verify-revenuecat-config.mjs
new file mode 100644
index 0000000..59eb3f3
--- /dev/null
+++ b/scripts/verify-revenuecat-config.mjs
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+const {
+ getRevenueCatKeyCategory,
+ resolveRevenueCatBuildConfig,
+} = require("../lib/subscription/revenueCatBuildConfig.js");
+const easConfig = JSON.parse(
+ await readFile(new URL("../eas.json", import.meta.url), "utf8"),
+);
+const googlePlayKey = ["goog", "configurationcheck"].join("_");
+const testStoreKey = ["test", "configurationcheck"].join("_");
+
+for (const profileName of ["development", "preview", "production"]) {
+ const profile = resolveProfile(profileName, new Set());
+ const mode = profile.env?.EXPO_PUBLIC_REVENUECAT_MODE;
+ const selectedConfig = resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: profile.environment,
+ buildPlatform: "android",
+ buildProfile: profileName,
+ mode,
+ testApiKey: profileName === "development" ? testStoreKey : undefined,
+ });
+
+ console.log(`profile: ${profileName}`);
+ console.log(`RevenueCat mode: ${selectedConfig.mode}`);
+ console.log(`selected key present: ${selectedConfig.apiKey ? "yes" : "no"}`);
+ console.log(
+ `selected key category: ${getRevenueCatKeyCategory(selectedConfig.apiKey)}`,
+ );
+ console.log(
+ `development client: ${profile.developmentClient === true ? "true" : "false"}`,
+ );
+ console.log(`distribution: ${profile.distribution ?? "store"}`);
+ const buildType =
+ profile.developmentClient === true
+ ? "development-client apk"
+ : (profile.android?.buildType ?? "app-bundle");
+
+ console.log(`build type: ${buildType}`);
+ console.log(`environment: ${profile.environment}`);
+}
+
+assert.throws(
+ () =>
+ resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "production",
+ buildPlatform: "android",
+ buildProfile: "production",
+ mode: "test-store",
+ testApiKey: testStoreKey,
+ }),
+ /Production|production builds/,
+);
+console.log("production Test Store selection impossible: yes");
+
+function resolveProfile(profileName, resolvingProfiles) {
+ assert(
+ !resolvingProfiles.has(profileName),
+ `Circular EAS profile inheritance for ${profileName}.`,
+ );
+
+ const profile = easConfig.build?.[profileName];
+
+ assert(profile, `Missing EAS build profile: ${profileName}.`);
+
+ if (!profile.extends) {
+ return profile;
+ }
+
+ resolvingProfiles.add(profileName);
+ const parentProfile = resolveProfile(profile.extends, resolvingProfiles);
+ resolvingProfiles.delete(profileName);
+
+ return mergeObjects(parentProfile, profile);
+}
+
+function mergeObjects(parent, child) {
+ const merged = { ...parent, ...child };
+
+ for (const key of ["android", "env", "ios"]) {
+ if (parent[key] || child[key]) {
+ merged[key] = { ...(parent[key] ?? {}), ...(child[key] ?? {}) };
+ }
+ }
+
+ return merged;
+}
diff --git a/tests/environment.test.ts b/tests/environment.test.ts
index dc7f79d..2dfe5bd 100644
--- a/tests/environment.test.ts
+++ b/tests/environment.test.ts
@@ -112,8 +112,6 @@ 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",
diff --git a/tests/revenuecat-build-config.test.mjs b/tests/revenuecat-build-config.test.mjs
new file mode 100644
index 0000000..4018566
--- /dev/null
+++ b/tests/revenuecat-build-config.test.mjs
@@ -0,0 +1,336 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+const {
+ resolveRevenueCatBuildConfig,
+} = require("../lib/subscription/revenueCatBuildConfig.js");
+const getExpoConfig = require("../app.config.js");
+const easConfig = JSON.parse(
+ readFileSync(new URL("../eas.json", import.meta.url), "utf8"),
+);
+
+const googlePlayKey = ["goog", "examplepublickey"].join("_");
+const testStoreKey = ["test", "examplepublickey"].join("_");
+const appStoreKey = ["appl", "examplepublickey"].join("_");
+
+assert.deepEqual(easConfig.build.development, {
+ developmentClient: true,
+ distribution: "internal",
+ environment: "development",
+ env: {
+ EXPO_PUBLIC_REVENUECAT_MODE: "test-store",
+ },
+});
+assert.equal(
+ "subscription-test" in easConfig.build,
+ false,
+ "Development must be the only Test Store build profile.",
+);
+assert.equal(easConfig.build.preview.developmentClient, false);
+assert.equal(easConfig.build.preview.distribution, "internal");
+assert.equal(easConfig.build.preview.environment, "preview");
+assert.equal(easConfig.build.preview.env.EXPO_PUBLIC_REVENUECAT_MODE, "google-play");
+assert.equal(easConfig.build.preview.android.buildType, "apk");
+assert.equal(easConfig.build.production.environment, "production");
+assert.equal(
+ easConfig.build.production.env.EXPO_PUBLIC_REVENUECAT_MODE,
+ "google-play",
+);
+assert.equal(easConfig.build.production.android.buildType, "app-bundle");
+assert.notEqual(easConfig.build.production.developmentClient, true);
+
+const developmentConfig = resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "development",
+ buildPlatform: "android",
+ buildProfile: "development",
+ mode: "test-store",
+ testApiKey: testStoreKey,
+});
+
+assert.deepEqual(developmentConfig, {
+ apiKey: testStoreKey,
+ mode: "test-store",
+});
+assert.deepEqual(
+ Object.keys(developmentConfig).sort(),
+ ["apiKey", "mode"],
+ "Runtime config must expose only the explicitly selected key and mode.",
+);
+
+const previewConfig = resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "preview",
+ buildPlatform: "android",
+ buildProfile: "preview",
+ mode: "google-play",
+ testApiKey: testStoreKey,
+});
+
+assert.deepEqual(previewConfig, {
+ apiKey: googlePlayKey,
+ mode: "google-play",
+});
+
+const productionConfig = resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "production",
+ buildPlatform: "android",
+ buildProfile: "production",
+ mode: "google-play",
+});
+
+assert.deepEqual(productionConfig, {
+ apiKey: googlePlayKey,
+ mode: "google-play",
+});
+
+const iosPreviewConfig = resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "preview",
+ buildPlatform: "ios",
+ buildProfile: "preview",
+ iosApiKey: appStoreKey,
+ mode: "google-play",
+});
+
+assert.equal(
+ iosPreviewConfig.apiKey,
+ appStoreKey,
+ "Existing iOS builds must continue selecting the iOS public SDK key.",
+);
+
+const missingDevelopmentKeyError = captureError(() =>
+ resolveRevenueCatBuildConfig({
+ appEnvironment: "development",
+ buildPlatform: "android",
+ buildProfile: "development",
+ mode: "test-store",
+ }),
+);
+
+assert.match(missingDevelopmentKeyError.message, /public SDK key is required/);
+assert.equal(missingDevelopmentKeyError.message.includes(testStoreKey), false);
+assert.equal(missingDevelopmentKeyError.message.includes(googlePlayKey), false);
+
+assert.throws(
+ () =>
+ resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "development",
+ buildPlatform: "android",
+ buildProfile: "development",
+ mode: "google-play",
+ testApiKey: testStoreKey,
+ }),
+ /development builds must use RevenueCat test-store mode/,
+);
+
+assert.throws(
+ () =>
+ resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "production",
+ buildPlatform: "android",
+ buildProfile: "production",
+ mode: "test-store",
+ testApiKey: testStoreKey,
+ }),
+ /Production|production builds/,
+);
+
+assert.throws(
+ () =>
+ resolveRevenueCatBuildConfig({
+ androidApiKey: googlePlayKey,
+ appEnvironment: "production",
+ buildPlatform: "android",
+ buildProfile: "production",
+ mode: "google-play",
+ testApiKey: testStoreKey,
+ }),
+ /must not define a RevenueCat Test Store/,
+);
+
+assert.throws(
+ () =>
+ resolveRevenueCatBuildConfig({
+ androidApiKey: testStoreKey,
+ appEnvironment: "preview",
+ buildPlatform: "android",
+ buildProfile: "preview",
+ mode: "google-play",
+ }),
+ /cannot select a Test Store/,
+);
+
+assert.throws(
+ () =>
+ resolveRevenueCatBuildConfig({
+ androidApiKey: appStoreKey,
+ appEnvironment: "production",
+ buildPlatform: "android",
+ buildProfile: "production",
+ mode: "google-play",
+ }),
+ /cannot select an App Store/,
+);
+
+let logCount = 0;
+const originalConsoleLog = console.log;
+const originalConsoleWarn = console.warn;
+const originalConsoleError = console.error;
+const countLog = () => {
+ logCount += 1;
+};
+
+try {
+ console.log = countLog;
+ console.warn = countLog;
+ console.error = countLog;
+
+ resolveRevenueCatBuildConfig({
+ appEnvironment: "development",
+ buildPlatform: "android",
+ buildProfile: "development",
+ mode: "test-store",
+ testApiKey: testStoreKey,
+ });
+
+ captureError(() =>
+ resolveRevenueCatBuildConfig({
+ appEnvironment: "development",
+ buildPlatform: "android",
+ buildProfile: "development",
+ mode: "test-store",
+ }),
+ );
+} finally {
+ console.log = originalConsoleLog;
+ console.warn = originalConsoleWarn;
+ console.error = originalConsoleError;
+}
+
+assert.equal(logCount, 0, "RevenueCat configuration must not log API keys.");
+
+const baseExpoConfig = {
+ extra: { eas: { projectId: "preserved-project" } },
+ plugins: ["expo-dev-client"],
+};
+const effectiveDevelopmentConfig = getEffectiveExpoConfig(
+ {
+ EAS_BUILD_PLATFORM: "android",
+ EAS_BUILD_PROFILE: "development",
+ EXPO_PUBLIC_APP_ENV: "development",
+ EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: googlePlayKey,
+ EXPO_PUBLIC_REVENUECAT_MODE: "test-store",
+ EXPO_PUBLIC_REVENUECAT_TEST_API_KEY: testStoreKey,
+ },
+ baseExpoConfig,
+);
+
+assert.equal(effectiveDevelopmentConfig.extra.revenueCat.apiKey, testStoreKey);
+assert.equal(effectiveDevelopmentConfig.extra.revenueCat.mode, "test-store");
+assert.equal(effectiveDevelopmentConfig.extra.eas.projectId, "preserved-project");
+assert.deepEqual(effectiveDevelopmentConfig.plugins[0], [
+ "expo-dev-client",
+ { addGeneratedScheme: true },
+]);
+
+const effectivePreviewConfig = getEffectiveExpoConfig(
+ {
+ EAS_BUILD_PLATFORM: "android",
+ EAS_BUILD_PROFILE: "preview",
+ EXPO_PUBLIC_APP_ENV: "preview",
+ EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: googlePlayKey,
+ EXPO_PUBLIC_REVENUECAT_MODE: "google-play",
+ EXPO_PUBLIC_REVENUECAT_TEST_API_KEY: undefined,
+ },
+ baseExpoConfig,
+);
+
+assert.equal(effectivePreviewConfig.extra.revenueCat.apiKey, googlePlayKey);
+assert.equal(effectivePreviewConfig.extra.revenueCat.mode, "google-play");
+assert.deepEqual(effectivePreviewConfig.plugins[0], [
+ "expo-dev-client",
+ { addGeneratedScheme: false },
+]);
+
+const effectiveProductionConfig = getEffectiveExpoConfig(
+ {
+ EAS_BUILD_PLATFORM: "android",
+ EAS_BUILD_PROFILE: "production",
+ EXPO_PUBLIC_APP_ENV: "production",
+ EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: googlePlayKey,
+ EXPO_PUBLIC_REVENUECAT_MODE: "google-play",
+ EXPO_PUBLIC_REVENUECAT_TEST_API_KEY: undefined,
+ },
+ baseExpoConfig,
+);
+
+assert.equal(effectiveProductionConfig.extra.revenueCat.apiKey, googlePlayKey);
+assert.equal(effectiveProductionConfig.extra.revenueCat.mode, "google-play");
+assert.deepEqual(effectiveProductionConfig.plugins[0], [
+ "expo-dev-client",
+ { addGeneratedScheme: false },
+]);
+
+const effectiveUnprofiledConfig = getEffectiveExpoConfig(
+ {
+ EAS_BUILD_PLATFORM: "android",
+ EAS_BUILD_PROFILE: undefined,
+ EXPO_PUBLIC_APP_ENV: "development",
+ EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: googlePlayKey,
+ EXPO_PUBLIC_REVENUECAT_MODE: "google-play",
+ EXPO_PUBLIC_REVENUECAT_TEST_API_KEY: undefined,
+ },
+ baseExpoConfig,
+);
+
+assert.deepEqual(effectiveUnprofiledConfig.plugins[0], [
+ "expo-dev-client",
+ { addGeneratedScheme: false },
+]);
+
+function captureError(action) {
+ try {
+ action();
+ } catch (error) {
+ assert(error instanceof Error);
+ return error;
+ }
+
+ throw new Error("Expected action to throw.");
+}
+
+function getEffectiveExpoConfig(environment, config) {
+ return withEnvironment(environment, () => getExpoConfig({ config }));
+}
+
+function withEnvironment(values, action) {
+ const previousValues = new Map();
+
+ for (const [name, value] of Object.entries(values)) {
+ previousValues.set(name, process.env[name]);
+
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+ }
+
+ try {
+ return action();
+ } finally {
+ for (const [name, value] of previousValues) {
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+ }
+ }
+}
diff --git a/tests/revenuecat-runtime.test.ts b/tests/revenuecat-runtime.test.ts
new file mode 100644
index 0000000..762caa9
--- /dev/null
+++ b/tests/revenuecat-runtime.test.ts
@@ -0,0 +1,236 @@
+// @ts-expect-error Node's built-in TypeScript runner requires an explicit extension.
+import { createKeyedRequestDeduper } from "../lib/subscription/requestDeduper.ts";
+// @ts-expect-error Node's built-in TypeScript runner requires an explicit extension.
+import { synchronizeRevenueCatIdentity } from "../lib/subscription/revenueCatIdentity.ts";
+// @ts-expect-error Node's built-in TypeScript runner requires an explicit extension.
+import { createRunOnce } from "../lib/subscription/runOnce.ts";
+// @ts-expect-error Node's built-in TypeScript runner requires an explicit extension.
+import { hasVerifiedEntitlement, isEntitlementEnvironmentAuthoritative, isRevenueCatIdentityCurrent } from "../lib/subscription/subscriptionState.ts";
+
+type TestCustomerInfo = {
+ entitlements: {
+ active: Record;
+ };
+ owner: string;
+};
+
+const entitlementId = "DearDiary Pro";
+
+function assert(condition: boolean, message: string): asserts condition {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function customerInfo(owner: string, isPro: boolean): TestCustomerInfo {
+ return {
+ entitlements: {
+ active: isPro ? { [entitlementId]: { isActive: true } } : {},
+ },
+ owner,
+ };
+}
+
+async function run() {
+ const configureGuard = createRunOnce();
+ let configureCount = 0;
+
+ configureGuard.run(() => {
+ configureCount += 1;
+ });
+ configureGuard.run(() => {
+ configureCount += 1;
+ });
+ assert(configureCount === 1, "RevenueCat must configure once per process.");
+
+ let anonymousLogoutCount = 0;
+ await synchronizeRevenueCatIdentity(
+ {
+ getAppUserID: async () => "$RCAnonymousID:example",
+ getCustomerInfo: async () => customerInfo("anonymous", false),
+ isAnonymous: async () => true,
+ logIn: async () => undefined,
+ logOut: async () => {
+ anonymousLogoutCount += 1;
+ },
+ },
+ null,
+ );
+ assert(
+ anonymousLogoutCount === 0,
+ "An anonymous RevenueCat customer must not be logged out again.",
+ );
+
+ let repeatedLoginCount = 0;
+ const identifiedResult = await synchronizeRevenueCatIdentity(
+ {
+ getAppUserID: async () => "user-a",
+ getCustomerInfo: async () => customerInfo("user-a", true),
+ isAnonymous: async () => false,
+ logIn: async () => {
+ repeatedLoginCount += 1;
+ },
+ logOut: async () => undefined,
+ },
+ "user-a",
+ );
+ assert(repeatedLoginCount === 0, "An active Clerk user must be identified once.");
+ assert(
+ identifiedResult.customerInfo?.owner === "user-a",
+ "Identity synchronization must refresh CustomerInfo for the active user.",
+ );
+
+ let currentRevenueCatUser = "user-a";
+ let accountSwitchLoginCount = 0;
+ let accountSwitchLogoutCount = 0;
+ const switchedResult = await synchronizeRevenueCatIdentity(
+ {
+ getAppUserID: async () => currentRevenueCatUser,
+ getCustomerInfo: async () => customerInfo(currentRevenueCatUser, false),
+ isAnonymous: async () => false,
+ logIn: async (nextUserId) => {
+ accountSwitchLoginCount += 1;
+ currentRevenueCatUser = nextUserId;
+ },
+ logOut: async () => {
+ accountSwitchLogoutCount += 1;
+ },
+ },
+ "user-b",
+ );
+ assert(accountSwitchLoginCount === 1, "Account switching must identify User B once.");
+ assert(
+ accountSwitchLogoutCount === 0,
+ "A direct identified account switch must not create an anonymous transition.",
+ );
+ assert(
+ switchedResult.customerInfo?.owner === "user-b",
+ "Account switching must publish only User B CustomerInfo.",
+ );
+
+ assert(
+ !isRevenueCatIdentityCurrent({
+ activeUserId: "user-b",
+ revenueCatUserId: "user-a",
+ synchronizedUserId: null,
+ }),
+ "An account transition must reject stale User A subscription updates.",
+ );
+ assert(
+ !hasVerifiedEntitlement({
+ customerInfo: customerInfo("user-a", true),
+ entitlementId,
+ identityMatches: false,
+ isAuthoritative: true,
+ }),
+ "User A Plus must never be shown for User B.",
+ );
+
+ const deduper = createKeyedRequestDeduper();
+ let resolveOffering: (value: string) => void = () => {
+ throw new Error("The offering request was not initialized.");
+ };
+ let offeringRequestCount = 0;
+ const firstOfferingRequest = deduper.run("test-store:user-a", () => {
+ offeringRequestCount += 1;
+ return new Promise((resolve) => {
+ resolveOffering = resolve;
+ });
+ });
+ const secondOfferingRequest = deduper.run("test-store:user-a", async () => {
+ offeringRequestCount += 1;
+ return "unexpected";
+ });
+ assert(
+ firstOfferingRequest === secondOfferingRequest,
+ "Concurrent offering consumers must share one request.",
+ );
+ assert(offeringRequestCount === 1, "Only one initial offering request may run.");
+ resolveOffering("offering");
+ await firstOfferingRequest;
+
+ const retryDeduper = createKeyedRequestDeduper();
+ let failedOfferingRequestCount = 0;
+ const failedRequest = () =>
+ retryDeduper.run("google-play:user-a", async () => {
+ failedOfferingRequestCount += 1;
+ throw new Error("unavailable");
+ });
+ await Promise.allSettled([failedRequest(), failedRequest()]);
+ assert(
+ failedOfferingRequestCount === 1,
+ "A failed offering request must not create a concurrent request loop.",
+ );
+ const explicitRetry = await retryDeduper.run(
+ "google-play:user-a",
+ async () => {
+ failedOfferingRequestCount += 1;
+ return "recovered";
+ },
+ );
+ assert(
+ explicitRetry === "recovered" &&
+ Number(failedOfferingRequestCount) === 2,
+ "Network recovery must allow an explicit offering retry.",
+ );
+
+ assert(
+ hasVerifiedEntitlement({
+ customerInfo: customerInfo("user-a", true),
+ entitlementId,
+ identityMatches: true,
+ isAuthoritative: true,
+ }),
+ "A successful purchase must require the intended active entitlement.",
+ );
+ assert(
+ !hasVerifiedEntitlement({
+ customerInfo: customerInfo("user-a", false),
+ entitlementId,
+ identityMatches: true,
+ isAuthoritative: true,
+ }),
+ "A cancelled purchase must not grant Plus.",
+ );
+ assert(
+ !hasVerifiedEntitlement({
+ customerInfo: null,
+ entitlementId,
+ identityMatches: true,
+ isAuthoritative: true,
+ }),
+ "A failed purchase must not grant Plus.",
+ );
+ assert(
+ !hasVerifiedEntitlement({
+ customerInfo: customerInfo("user-a", false),
+ entitlementId,
+ identityMatches: true,
+ isAuthoritative: true,
+ }),
+ "Restore must require a verified active entitlement.",
+ );
+
+ const expiredAccess = hasVerifiedEntitlement({
+ customerInfo: customerInfo("user-a", false),
+ entitlementId,
+ identityMatches: true,
+ isAuthoritative: true,
+ });
+ assert(!expiredAccess, "An expired entitlement must remove Plus access.");
+ assert(
+ !hasVerifiedEntitlement({
+ customerInfo: null,
+ entitlementId,
+ identityMatches: false,
+ isAuthoritative: true,
+ }),
+ "RevenueCat unavailability must fail closed without affecting journaling data.",
+ );
+ assert(
+ !isEntitlementEnvironmentAuthoritative("storeClient"),
+ "Expo Go Preview API mode must not be authoritative entitlement proof.",
+ );
+}
+
+void run();