From 7fde5dcdf797389bc5e770d0286d3ea213752ba2 Mon Sep 17 00:00:00 2001 From: tsyirvo Date: Wed, 7 Jan 2026 10:54:13 +0100 Subject: [PATCH 1/2] feat: rework how subscriptions are handled --- src/app/_layout.tsx | 45 +++++---- .../authContext/AuthContextProvider.tsx | 2 +- .../SubscriptionContext.ts | 12 +++ .../SubscriptionContextProvider.tsx | 98 +++++++++++++++++++ .../contexts/subscriptionContext/index.ts | 2 + .../useSubscriptionContext.ts | 9 ++ src/domain/subscription/index.ts | 1 + .../utils/hasActiveEntitlements.ts | 5 + src/domain/subscription/utils/index.ts | 1 + src/infra/analytics/analytics.ts | 10 -- src/infra/analytics/analytics.types.ts | 10 -- src/infra/featureFlags/defaultFlags.ts | 4 +- src/infra/featureFlags/featureFlags.types.ts | 8 +- src/infra/purchase/purchase.ts | 30 ++++-- src/shared/hooks/useRunOnMount.ts | 22 ++++- 15 files changed, 207 insertions(+), 52 deletions(-) create mode 100644 src/domain/contexts/subscriptionContext/SubscriptionContext.ts create mode 100644 src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx create mode 100644 src/domain/contexts/subscriptionContext/index.ts create mode 100644 src/domain/contexts/subscriptionContext/useSubscriptionContext.ts create mode 100644 src/domain/subscription/index.ts create mode 100644 src/domain/subscription/utils/hasActiveEntitlements.ts create mode 100644 src/domain/subscription/utils/index.ts diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index adc2a922..4445be92 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -17,6 +17,7 @@ import { StyleSheet } from 'react-native-unistyles'; import { config } from '$domain/constants'; import { AuthContextProvider } from '$domain/contexts'; +import { SubscriptionContextProvider } from '$domain/contexts/subscriptionContext'; import { useAppFocusManager } from '$infra/api'; import { persistOptions, queryClient } from '$infra/api/queryClient'; import { ErrorMonitoring } from '$infra/monitoring'; @@ -81,29 +82,35 @@ const RootLayout = () => { - <> - - - - + + <> + + + + + + + + + + + + + + - - - - - - - - - - - + - + - - + + + diff --git a/src/domain/contexts/authContext/AuthContextProvider.tsx b/src/domain/contexts/authContext/AuthContextProvider.tsx index 7cbb318d..535beeba 100644 --- a/src/domain/contexts/authContext/AuthContextProvider.tsx +++ b/src/domain/contexts/authContext/AuthContextProvider.tsx @@ -40,7 +40,7 @@ export const AuthContextProvider = ({ children }: AuthContextProviderProps) => { ErrorMonitoring.setUser(user); - await Purchase.setUser(user.id); + await Purchase.setUser(user); Notifications.setUser(user.id); Notifications.setUserEmail(user.email); diff --git a/src/domain/contexts/subscriptionContext/SubscriptionContext.ts b/src/domain/contexts/subscriptionContext/SubscriptionContext.ts new file mode 100644 index 00000000..10ef71fd --- /dev/null +++ b/src/domain/contexts/subscriptionContext/SubscriptionContext.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react'; +import { PurchasesOffering } from 'react-native-purchases'; + +const SubscriptionContext = createContext<{ + isPayingUser: boolean | null; + offeringToDisplay: PurchasesOffering | null; +}>({ + isPayingUser: null, + offeringToDisplay: null, +}); + +export default SubscriptionContext; diff --git a/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx b/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx new file mode 100644 index 00000000..b334d6c9 --- /dev/null +++ b/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx @@ -0,0 +1,98 @@ +import { useMemo, useState } from 'react'; +import { PurchasesOffering } from 'react-native-purchases'; + +import { OfferingFlagType, useGetRemoteConfigSync } from '$infra/featureFlags'; +import { Logger } from '$infra/logger'; +import { Purchase } from '$infra/purchase'; +import { useRunOnMount } from '$shared/hooks'; + +import SubscriptionContext from './SubscriptionContext'; + +interface SubscriptionContextProviderProps { + children: React.ReactNode; +} + +export const SubscriptionContextProvider = ({ + children, +}: SubscriptionContextProviderProps) => { + const [isPayingUser, setIsPayingUser] = useState(null); + const [offeringToDisplay, setOfferingToDisplay] = + useState(null); + + const { getFlagPayloadSync } = useGetRemoteConfigSync(); + + useRunOnMount(() => { + const fetchIsPayingUser = async () => { + try { + const isPayingUser = await Purchase.isPayingUser(); + + setIsPayingUser(isPayingUser); + } catch (error) { + Logger.error({ + error, + level: 'warning', + message: 'Failed to fetch user subscription status', + }); + + setIsPayingUser(false); + } + }; + + void fetchIsPayingUser(); + }); + + useRunOnMount(() => { + return Purchase.customerListener((customerInfo) => { + setIsPayingUser( + Object.entries(customerInfo.entitlements.active).length > 0, + ); + }); + }); + + useRunOnMount(() => { + const fetchOfferingToDisplay = async () => { + try { + const offering = await Purchase.getOfferings(); + const payload = getFlagPayloadSync( + 'offering-to-display', + ); + + if (payload?.type === 'offering' && payload.offering) { + const remotelySelectedOffering = offering.all[payload.offering]; + + if (remotelySelectedOffering) { + setOfferingToDisplay(remotelySelectedOffering); + } else { + setOfferingToDisplay(offering.current); + } + } else { + setOfferingToDisplay(offering.current); + } + } catch (error) { + Logger.error({ + error, + level: 'warning', + message: 'Failed to fetch offering to display', + }); + + setOfferingToDisplay(null); + } + }; + + void fetchOfferingToDisplay(); + }); + + const value = useMemo( + () => ({ + isPayingUser, + offeringToDisplay, + }), + [isPayingUser, offeringToDisplay], + ); + + return ( + + {children} + + ); +}; diff --git a/src/domain/contexts/subscriptionContext/index.ts b/src/domain/contexts/subscriptionContext/index.ts new file mode 100644 index 00000000..0951b8de --- /dev/null +++ b/src/domain/contexts/subscriptionContext/index.ts @@ -0,0 +1,2 @@ +export * from './SubscriptionContextProvider'; +export * from './useSubscriptionContext'; diff --git a/src/domain/contexts/subscriptionContext/useSubscriptionContext.ts b/src/domain/contexts/subscriptionContext/useSubscriptionContext.ts new file mode 100644 index 00000000..98733c09 --- /dev/null +++ b/src/domain/contexts/subscriptionContext/useSubscriptionContext.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react'; + +import SubscriptionContext from './SubscriptionContext'; + +export const useSubscriptionContext = () => { + const value = useContext(SubscriptionContext); + + return value; +}; diff --git a/src/domain/subscription/index.ts b/src/domain/subscription/index.ts new file mode 100644 index 00000000..04bca77e --- /dev/null +++ b/src/domain/subscription/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/src/domain/subscription/utils/hasActiveEntitlements.ts b/src/domain/subscription/utils/hasActiveEntitlements.ts new file mode 100644 index 00000000..87a96762 --- /dev/null +++ b/src/domain/subscription/utils/hasActiveEntitlements.ts @@ -0,0 +1,5 @@ +import { CustomerInfo } from 'react-native-purchases'; + +export const hasActiveEntitlements = (customerInfo: CustomerInfo): boolean => { + return Object.entries(customerInfo.entitlements.active).length > 0; +}; diff --git a/src/domain/subscription/utils/index.ts b/src/domain/subscription/utils/index.ts new file mode 100644 index 00000000..dd850d65 --- /dev/null +++ b/src/domain/subscription/utils/index.ts @@ -0,0 +1 @@ +export * from './hasActiveEntitlements'; diff --git a/src/infra/analytics/analytics.ts b/src/infra/analytics/analytics.ts index 272f46cf..1f73809c 100644 --- a/src/infra/analytics/analytics.ts +++ b/src/infra/analytics/analytics.ts @@ -41,16 +41,6 @@ class AnalyticsClass { }); } - /* ***** ***** Revenue ***** ***** */ - - trackRevenue(revenueData: { - productId: AnalyticsType.ProductIds; - price: number; - revenueType: AnalyticsType.RevenueTypes; - }) { - this.trackEvent('purchase', revenueData); - } - /* ***** ***** Events ***** ***** */ trackEvent( diff --git a/src/infra/analytics/analytics.types.ts b/src/infra/analytics/analytics.types.ts index b13fa32e..d87954a6 100644 --- a/src/infra/analytics/analytics.types.ts +++ b/src/infra/analytics/analytics.types.ts @@ -24,16 +24,6 @@ export namespace AnalyticsType { | 'deep-link-opened' | 'purchase'; - export type ProductIds = - | 'monthly-subscription' - | 'monthly-subscription-30-off' - | 'monthly-subscription-50-off' - | 'yearly-subscription' - | 'yearly-subscription-30-off' - | 'yearly-subscription-50-off'; - - export type RevenueTypes = 'purchase'; - export type JsonType = | string | number diff --git a/src/infra/featureFlags/defaultFlags.ts b/src/infra/featureFlags/defaultFlags.ts index 645f188c..13d26656 100644 --- a/src/infra/featureFlags/defaultFlags.ts +++ b/src/infra/featureFlags/defaultFlags.ts @@ -1,6 +1,7 @@ import type { AvailableFeatureFlags, AvailableRemoteConfig, + OfferingFlagType, VersionFlagType, } from './featureFlags.types'; @@ -13,8 +14,9 @@ export const defaultFeatureFlags: Record< export const defaultRemoteConfig: Record< AvailableRemoteConfig, - VersionFlagType + VersionFlagType | OfferingFlagType > = { 'last-supported-app-version': { type: 'version', version: '2.0.0' }, 'latest-released-app-version': { type: 'version', version: '2.1.0' }, + 'offering-to-display': { type: 'offering', offering: 'default' }, }; diff --git a/src/infra/featureFlags/featureFlags.types.ts b/src/infra/featureFlags/featureFlags.types.ts index 26c8b3e6..b4080bec 100644 --- a/src/infra/featureFlags/featureFlags.types.ts +++ b/src/infra/featureFlags/featureFlags.types.ts @@ -2,13 +2,19 @@ export type AvailableFeatureFlags = 'is-maintenance-mode'; export type AvailableRemoteConfig = | 'last-supported-app-version' - | 'latest-released-app-version'; + | 'latest-released-app-version' + | 'offering-to-display'; export type BooleanFeatureFlags = Extract< AvailableFeatureFlags, 'is-maintenance-mode' >; +export interface OfferingFlagType { + type: 'offering'; + offering: string; +} + export interface VersionFlagType { type: 'version'; version: string; diff --git a/src/infra/purchase/purchase.ts b/src/infra/purchase/purchase.ts index aea63e16..c5c6e5e6 100644 --- a/src/infra/purchase/purchase.ts +++ b/src/infra/purchase/purchase.ts @@ -3,9 +3,13 @@ import type { LOG_LEVEL, PurchasesPackage, } from 'react-native-purchases'; -import RevenueCat from 'react-native-purchases'; +import RevenueCat, { + LOG_LEVEL as PURCHASES_LOG_LEVEL, +} from 'react-native-purchases'; import { IS_IOS, config } from '$domain/constants'; +import { User } from '$domain/entities'; +import { hasActiveEntitlements } from '$domain/subscription'; import { ErrorMonitoring } from '$infra/monitoring'; const API_KEY = IS_IOS @@ -17,6 +21,8 @@ class PurchaseClass { init() { RevenueCat.configure({ apiKey: API_KEY }); + + void this.setLogLevel(PURCHASES_LOG_LEVEL.ERROR); } async setLogLevel(logLevel: LOG_LEVEL) { @@ -25,8 +31,12 @@ class PurchaseClass { /* ***** ***** User related ***** ***** */ - async setUser(appUserID: string) { - await RevenueCat.logIn(appUserID); + async setUser(user: User) { + await RevenueCat.logIn(user.id); + await RevenueCat.setEmail(user.email); + await this.setAttributes({ + $posthogUserId: user.id, + }); } async clearUser() { @@ -45,12 +55,16 @@ class PurchaseClass { return await RevenueCat.getCustomerInfo(); } + async isPayingUser() { + const customerInfo = await this.getUserInformations(); + + return hasActiveEntitlements(customerInfo); + } + /* ***** ***** RevenueCat ***** ***** */ async getOfferings() { - const offerings = await RevenueCat.getOfferings(); - - return offerings.current; + return await RevenueCat.getOfferings(); } async restorePurchases() { @@ -69,6 +83,10 @@ class PurchaseClass { customerListener(callback: (customerInfo: CustomerInfo) => void) { RevenueCat.addCustomerInfoUpdateListener(callback); + + return () => { + RevenueCat.removeCustomerInfoUpdateListener(callback); + }; } } diff --git a/src/shared/hooks/useRunOnMount.ts b/src/shared/hooks/useRunOnMount.ts index c3fc890f..57c08256 100644 --- a/src/shared/hooks/useRunOnMount.ts +++ b/src/shared/hooks/useRunOnMount.ts @@ -1,7 +1,21 @@ -import { type EffectCallback, useEffect } from 'react'; +import { type EffectCallback, useEffect, useRef } from 'react'; export const useRunOnMount = (callback: EffectCallback) => { - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(callback, []); + const hasRunRef = useRef(false); + const cleanupRef = useRef<(() => void) | undefined>(undefined); + + useEffect(() => { + if (!hasRunRef.current) { + hasRunRef.current = true; + const cleanup = callback(); + + if (typeof cleanup === 'function') { + cleanupRef.current = cleanup; + } + } + + return () => { + cleanupRef.current?.(); + }; + }, [callback]); }; From 6f986c9e23aa5913c9d6472b6de965ebaffb6635 Mon Sep 17 00:00:00 2001 From: tsyirvo Date: Wed, 7 Jan 2026 11:08:07 +0100 Subject: [PATCH 2/2] chore: handle PR review --- .../SubscriptionContextProvider.tsx | 5 ++--- src/shared/hooks/useRunOnMount.ts | 22 ++++--------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx b/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx index b334d6c9..3d15340e 100644 --- a/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx +++ b/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { PurchasesOffering } from 'react-native-purchases'; +import { hasActiveEntitlements } from '$domain/subscription'; import { OfferingFlagType, useGetRemoteConfigSync } from '$infra/featureFlags'; import { Logger } from '$infra/logger'; import { Purchase } from '$infra/purchase'; @@ -43,9 +44,7 @@ export const SubscriptionContextProvider = ({ useRunOnMount(() => { return Purchase.customerListener((customerInfo) => { - setIsPayingUser( - Object.entries(customerInfo.entitlements.active).length > 0, - ); + setIsPayingUser(hasActiveEntitlements(customerInfo)); }); }); diff --git a/src/shared/hooks/useRunOnMount.ts b/src/shared/hooks/useRunOnMount.ts index 57c08256..c3fc890f 100644 --- a/src/shared/hooks/useRunOnMount.ts +++ b/src/shared/hooks/useRunOnMount.ts @@ -1,21 +1,7 @@ -import { type EffectCallback, useEffect, useRef } from 'react'; +import { type EffectCallback, useEffect } from 'react'; export const useRunOnMount = (callback: EffectCallback) => { - const hasRunRef = useRef(false); - const cleanupRef = useRef<(() => void) | undefined>(undefined); - - useEffect(() => { - if (!hasRunRef.current) { - hasRunRef.current = true; - const cleanup = callback(); - - if (typeof cleanup === 'function') { - cleanupRef.current = cleanup; - } - } - - return () => { - cleanupRef.current?.(); - }; - }, [callback]); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(callback, []); };