From 09696291224601d125769adeca2f3e860d6fa7a8 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 3 Aug 2026 14:57:50 -0500 Subject: [PATCH] Add RevenueCat Capacitor IAP; keep Stripe for web billing. Native builds purchase/restore via RevenueCat; browser keeps Stripe checkout/portal. Billing history shows store ledger rows from backend webhooks. Closes #100. --- llm-fe/.env.mobile | 6 + llm-fe/ANDROID.md | 14 ++ llm-fe/MONETIZATION.md | 40 ++++ llm-fe/package-lock.json | 19 ++ llm-fe/package.json | 1 + .../BillingSection/BillingSection.test.tsx | 62 +++++ .../BillingSection/BillingSection.tsx | 226 ++++++++++++++++-- .../DeleteAccountSection.test.tsx | 4 + .../DeleteAccountSection.tsx | 2 + .../components/Header2/Header2.test.tsx | 4 + .../src/llm-fe/components/Header2/Header2.tsx | 2 + llm-fe/src/llm-fe/contexts/AccountContext.tsx | 7 +- llm-fe/src/llm-fe/data.ts | 3 + .../pages/AuthCallback/AuthCallback.test.tsx | 5 + .../pages/AuthCallback/AuthCallback.tsx | 27 ++- .../src/llm-fe/pages/SignIn/SignIn.test.tsx | 4 + llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx | 5 + .../src/llm-fe/pages/SignUp/SignUp.test.tsx | 5 + llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx | 31 ++- llm-fe/src/llm-fe/utils/finance.test.ts | 41 +++- llm-fe/src/llm-fe/utils/finance.ts | 66 ++++- llm-fe/src/llm-fe/utils/revenueCat.test.ts | 49 ++++ llm-fe/src/llm-fe/utils/revenueCat.ts | 176 ++++++++++++++ llm-fe/src/react-app-env.d.ts | 1 + 24 files changed, 762 insertions(+), 38 deletions(-) create mode 100644 llm-fe/MONETIZATION.md create mode 100644 llm-fe/src/llm-fe/utils/revenueCat.test.ts create mode 100644 llm-fe/src/llm-fe/utils/revenueCat.ts diff --git a/llm-fe/.env.mobile b/llm-fe/.env.mobile index b203508..3fe8ee2 100644 --- a/llm-fe/.env.mobile +++ b/llm-fe/.env.mobile @@ -4,3 +4,9 @@ # REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/ REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/ + +# RevenueCat public SDK keys (native IAP). Leave empty until store apps are wired. +REACT_APP_REVENUECAT_APPLE_API_KEY= +REACT_APP_REVENUECAT_GOOGLE_API_KEY= +# Optional: pin a specific offering identifier (defaults to current offering). +# REACT_APP_REVENUECAT_OFFERING_ID= diff --git a/llm-fe/ANDROID.md b/llm-fe/ANDROID.md index 2dcfbd2..5a02b1e 100644 --- a/llm-fe/ANDROID.md +++ b/llm-fe/ANDROID.md @@ -40,6 +40,20 @@ npm run android:sync Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`. +### RevenueCat IAP (#100) + +Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal. + +Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship): + +| Variable | Purpose | +|----------|---------| +| `REACT_APP_REVENUECAT_APPLE_API_KEY` | iOS public SDK key | +| `REACT_APP_REVENUECAT_GOOGLE_API_KEY` | Android public SDK key | +| `REACT_APP_REVENUECAT_OFFERING_ID` | Optional offering pin (else current) | + +After install / key changes: `npm run build:mobile` (runs `cap sync`). More detail: [`MONETIZATION.md`](MONETIZATION.md). + ## Versioning In `android/app/build.gradle`: diff --git a/llm-fe/MONETIZATION.md b/llm-fe/MONETIZATION.md new file mode 100644 index 0000000..13d6ca6 --- /dev/null +++ b/llm-fe/MONETIZATION.md @@ -0,0 +1,40 @@ +# Monetization (web Stripe + native RevenueCat) + +## Channels + +| Runtime | Checkout / manage | Ledger | +|---------|-------------------|--------| +| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/finance/...` | Stripe webhooks | +| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables | + +FE displays whatever `/finance/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`. + +## App user id + +RevenueCat `appUserID` = Django user pk as string. + +1. Prefer `id` from `/user/get/` (serializer returns all model fields). +2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`). + +Backend webhook resolver accepts numeric pk (or email fallback). + +## Mobile env keys + +In `.env.mobile`: + +- `REACT_APP_REVENUECAT_APPLE_API_KEY` +- `REACT_APP_REVENUECAT_GOOGLE_API_KEY` +- `REACT_APP_REVENUECAT_OFFERING_ID` (optional) + +Package identifiers in the RC dashboard should include plan slugs (e.g. `founders`) so `purchasePlan(planSlug)` can match packages/products. Optional backend `REVENUECAT_PRODUCT_PLAN_MAP` maps product id → plan slug. + +## Auth hooks + +- `Purchases.configure` once on native (first purchase / logIn). +- `logIn(appUserID)` after SignIn / SignUp / AuthCallback / AccountContext load. +- `logOut` on Header2 sign-out and Delete account. + +## Related + +- Issue #100 (FE) + companion backend RevenueCat webhook PR +- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds diff --git a/llm-fe/package-lock.json b/llm-fe/package-lock.json index f876442..97c4c10 100644 --- a/llm-fe/package-lock.json +++ b/llm-fe/package-lock.json @@ -21,6 +21,7 @@ "@emotion/styled": "^11.14.0", "@mui/icons-material": "^5.16.11", "@mui/material": "^5.16.11", + "@revenuecat/purchases-capacitor": "^11.3.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", @@ -5160,6 +5161,24 @@ "react": ">=16.14.0" } }, + "node_modules/@revenuecat/purchases-capacitor": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-capacitor/-/purchases-capacitor-11.3.2.tgz", + "integrity": "sha512-3T4/lcAwpbPagrT4DuXvJ+8RewzFmHf3fQJQuhVy+uTQk5HGMHNlXcm2A/UdyHtWqU+/VqWbCClPFYGnpnNXAQ==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-typescript-internal-esm": "17.25.0" + }, + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@revenuecat/purchases-typescript-internal-esm": { + "version": "17.25.0", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal-esm/-/purchases-typescript-internal-esm-17.25.0.tgz", + "integrity": "sha512-KC4BjFaQclXqFafG1Enh7t8GSwdg8p905by7UrHq0WSJmLhOR6yXSj6WDv1iwsFSAZxPPwgozNxt+U6IOFTdCA==", + "license": "MIT" + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", diff --git a/llm-fe/package.json b/llm-fe/package.json index bcf5c10..074ceac 100644 --- a/llm-fe/package.json +++ b/llm-fe/package.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.14.0", "@mui/icons-material": "^5.16.11", "@mui/material": "^5.16.11", + "@revenuecat/purchases-capacitor": "^11.3.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx index 9978858..5f92429 100644 --- a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx @@ -8,6 +8,9 @@ const mockGet = jest.fn(); const mockPost = jest.fn(); const assignMock = jest.fn(); const mockTrackEvent = jest.fn(); +const mockPurchasePlan = jest.fn(); +const mockRestorePurchases = jest.fn(); +const mockIsNativePlatform = jest.fn(() => false); jest.mock('../../../axiosApi', () => ({ axiosInstance: { @@ -27,6 +30,17 @@ jest.mock('../../utils/analytics', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })); +jest.mock('../../platform/nativePlatform', () => ({ + isNativePlatform: () => mockIsNativePlatform(), +})); + +jest.mock('../../utils/revenueCat', () => ({ + purchasePlan: (...args: unknown[]) => mockPurchasePlan(...args), + restorePurchases: (...args: unknown[]) => mockRestorePurchases(...args), + isPurchaseCancelledError: (error: { userCancelled?: boolean }) => + Boolean(error?.userCancelled), +})); + const theme = { main: '#4a90e2', darkMode: true, @@ -174,6 +188,9 @@ describe('BillingSection', () => { mockPost.mockReset(); assignMock.mockReset(); mockTrackEvent.mockReset(); + mockPurchasePlan.mockReset(); + mockRestorePurchases.mockReset(); + mockIsNativePlatform.mockReturnValue(false); Object.defineProperty(window, 'location', { configurable: true, value: { @@ -345,4 +362,49 @@ describe('BillingSection', () => { expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument(); expect(assignMock).not.toHaveBeenCalled(); }); + + it('on native uses RevenueCat purchase and shows Restore', async () => { + mockIsNativePlatform.mockReturnValue(true); + mockFinanceGets(); + mockPurchasePlan.mockResolvedValue(undefined); + + const user = userEvent.setup(); + renderBilling(); + + expect(await screen.findByTestId('restore-purchases')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Complete payment/i })); + + await waitFor(() => { + expect(mockPurchasePlan).toHaveBeenCalled(); + }); + expect(mockPost).not.toHaveBeenCalledWith( + '/finance/checkout/', + expect.anything() + ); + }); + + it('shows provider badge for RevenueCat invoices', async () => { + mockFinanceGets({ + invoices: [ + { + ...paidInvoice, + provider: 'revenuecat', + stripe_subscription_id: null, + revenuecat_store: 'PLAY_STORE', + hosted_invoice_url: '', + description: 'Store IAP (PLAY_STORE) — founders — INITIAL_PURCHASE', + }, + ], + subscription: { + ...foundersSubscription, + source: 'revenuecat', + stripe_subscription_id: '', + }, + }); + + renderBilling(); + + expect(await screen.findByText('Play Store')).toBeInTheDocument(); + expect(screen.getByText(/Store IAP/)).toBeInTheDocument(); + }); }); diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx index 4212392..92dbd78 100644 --- a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx @@ -12,12 +12,21 @@ import { formatTokenCount, higherSelectablePlans, humanizeStatus, + invoiceProviderLabel, isComplimentarySubscription, + isStoreSubscription, + isStripeSubscription, otherSelectablePlans, pickPrimaryInvoice, SubscriptionMe, SubscriptionPlanInfo, } from '../../utils/finance'; +import { isNativePlatform } from '../../platform/nativePlatform'; +import { + isPurchaseCancelledError, + purchasePlan, + restorePurchases, +} from '../../utils/revenueCat'; const GlassCard = styled.div` background: ${({ theme }) => theme.colors.cardBackground}; @@ -233,6 +242,14 @@ const InvoiceLink = styled.a` } `; +const ProviderBadge = styled.span` + display: inline-block; + font-size: 0.8rem; + font-weight: 600; + opacity: 0.85; + white-space: nowrap; +`; + function apiErrorMessage(error: unknown, fallback: string): string { const axiosError = error as { response?: { data?: { detail?: string } }; @@ -241,9 +258,19 @@ function apiErrorMessage(error: unknown, fallback: string): string { return axiosError.response?.data?.detail || axiosError.message || fallback; } +function storeManageLabel(): string { + if (typeof window === 'undefined') return 'App Store / Play Store'; + const Cap = window.Capacitor as { getPlatform?: () => string } | undefined; + const platform = Cap?.getPlatform?.(); + if (platform === 'ios') return 'App Store'; + if (platform === 'android') return 'Play Store'; + return 'App Store / Play Store'; +} + type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel'; const BillingSection = (): JSX.Element => { + const native = isNativePlatform(); const [invoices, setInvoices] = useState([]); const [payments, setPayments] = useState([]); const [subscription, setSubscription] = useState(null); @@ -251,8 +278,10 @@ const BillingSection = (): JSX.Element => { const [loading, setLoading] = useState(true); const [listError, setListError] = useState(''); const [actionError, setActionError] = useState(''); + const [actionNotice, setActionNotice] = useState(''); const [portalLoading, setPortalLoading] = useState(false); const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState(null); + const [restoreLoading, setRestoreLoading] = useState(false); const [showPlanPicker, setShowPlanPicker] = useState(false); const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change'); const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); @@ -293,6 +322,16 @@ const BillingSection = (): JSX.Element => { () => isComplimentarySubscription(subscription, hasPortalAccess), [subscription, hasPortalAccess] ); + const storeSub = useMemo( + () => isStoreSubscription(subscription?.source), + [subscription?.source] + ); + const stripeSub = useMemo( + () => isStripeSubscription(subscription?.source) || hasPortalAccess, + [subscription?.source, hasPortalAccess] + ); + const showStripeManage = !complimentary && stripeSub && hasPortalAccess; + const showStoreManage = !complimentary && (storeSub || (native && !showStripeManage && Boolean(subscription?.plan) && !subscription?.needs_checkout)); const upgradePlans = useMemo( () => higherSelectablePlans(plans, subscription?.plan), [plans, subscription?.plan] @@ -306,6 +345,7 @@ const BillingSection = (): JSX.Element => { subscription?.current_period_end || primaryInvoice?.period_end || null; return formatBillingDate(end); }, [subscription?.current_period_end, primaryInvoice?.period_end]); + const storeLabel = useMemo(() => storeManageLabel(), []); const historyRows = useMemo(() => { if (invoices.length) { @@ -313,6 +353,7 @@ const BillingSection = (): JSX.Element => { key: `invoice-${invoice.id}`, date: invoice.created, description: invoice.description || 'Invoice', + provider: invoiceProviderLabel(invoice), amount: formatMoneyCents( invoice.amount_paid || invoice.amount_due, invoice.currency @@ -325,6 +366,12 @@ const BillingSection = (): JSX.Element => { key: `payment-${payment.id}`, date: payment.paid_at || payment.created, description: 'Payment', + provider: + (payment.provider || '').toLowerCase() === 'revenuecat' + ? 'Store' + : (payment.provider || '').toLowerCase() === 'stripe' + ? 'Stripe' + : payment.provider || '—', amount: formatMoneyCents(payment.amount, payment.currency), status: humanizeStatus(payment.status), url: '', @@ -333,6 +380,7 @@ const BillingSection = (): JSX.Element => { const openPortal = async (intent: PortalIntent) => { setActionError(''); + setActionNotice(''); setPortalLoading(true); try { const returnUrl = `${window.location.origin}/account/`; @@ -356,8 +404,55 @@ const BillingSection = (): JSX.Element => { } }; - const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => { + const handleNativePurchase = async (planSlug?: string, source = 'account_billing') => { setActionError(''); + setActionNotice(''); + setCheckoutLoadingSlug(planSlug || '__default__'); + try { + trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { + source, + ...(planSlug ? { plan_slug: planSlug } : {}), + }); + await purchasePlan(planSlug); + setActionNotice( + 'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.' + ); + await loadBilling(); + } catch (error: unknown) { + if (isPurchaseCancelledError(error)) { + setActionNotice('Purchase cancelled.'); + return; + } + setActionError( + apiErrorMessage(error, 'Store purchase failed. Try again or Restore purchases.') + ); + } finally { + setCheckoutLoadingSlug(null); + } + }; + + const handleRestore = async () => { + setActionError(''); + setActionNotice(''); + setRestoreLoading(true); + try { + await restorePurchases(); + setActionNotice('Purchases restored. Refreshing billing…'); + await loadBilling(); + } catch (error: unknown) { + setActionError(apiErrorMessage(error, 'Could not restore purchases. Try again.')); + } finally { + setRestoreLoading(false); + } + }; + + const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => { + if (native) { + await handleNativePurchase(planSlug, source); + return; + } + setActionError(''); + setActionNotice(''); setCheckoutLoadingSlug(planSlug || '__default__'); try { const { success_url, cancel_url } = checkoutReturnUrls(); @@ -395,6 +490,10 @@ const BillingSection = (): JSX.Element => { setShowPlanPicker(true); return; } + if (native && (storeSub || showStoreManage)) { + void handleNativePurchase(undefined, 'account_upgrade'); + return; + } void openPortal('upgrade'); }; @@ -405,10 +504,24 @@ const BillingSection = (): JSX.Element => { setShowPlanPicker(true); return; } + if (native && (storeSub || showStoreManage)) { + setActionNotice( + `Change or cancel your plan in ${storeLabel} subscription settings.` + ); + return; + } void openPortal('change'); }; const handleConfirmCancel = async () => { + if (native && (storeSub || showStoreManage) && !showStripeManage) { + trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'store' }); + setCancelConfirmOpen(false); + setActionNotice( + `Open ${storeLabel} → Subscriptions to cancel. Access usually continues until the period end.` + ); + return; + } trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' }); setCancelConfirmOpen(false); await openPortal('cancel'); @@ -422,7 +535,6 @@ const BillingSection = (): JSX.Element => { source: 'plan_picker', plan_slug: plan.slug, }); - // New higher tier via Checkout when selectable; portal otherwise. await handleStartCheckout(plan.slug, 'account_upgrade'); return; } @@ -430,7 +542,12 @@ const BillingSection = (): JSX.Element => { source: 'plan_picker', plan_slug: plan.slug, }); - // Existing subscribers change plans in the Stripe portal (proration / PCI). + if (native && (storeSub || showStoreManage) && !showStripeManage) { + setShowPlanPicker(false); + await handleNativePurchase(plan.slug, 'account_change'); + return; + } + // Existing Stripe subscribers change plans in the portal (proration / PCI). setShowPlanPicker(false); await openPortal('change'); }; @@ -525,7 +642,7 @@ const BillingSection = (): JSX.Element => { {!loading && !listError && ( - {hasPortalAccess && !complimentary ? ( + {showStripeManage ? ( <> { Complimentary access — no payment required. Plan changes and cancellation are not available for this account. + ) : showStoreManage ? ( + <> + + Upgrade + + + Change plan + + {!subscription?.cancel_at_period_end && + subscription?.status !== 'canceled' ? ( + setCancelConfirmOpen(true)} + disabled={Boolean(checkoutLoadingSlug)} + > + Cancel + + ) : null} + + setActionNotice( + `Manage billing in ${storeLabel} → Subscriptions. Changes sync here after refresh.` + ) + } + data-testid="store-manage-hint" + > + Manage in {storeLabel} + + {native ? ( + + {restoreLoading ? 'Restoring…' : 'Restore purchases'} + + ) : null} + ) : ( - handleStartCheckout()} - disabled={Boolean(checkoutLoadingSlug)} - > - {checkoutLoadingSlug ? 'Starting…' : 'Complete payment'} - + <> + handleStartCheckout()} + disabled={Boolean(checkoutLoadingSlug) || restoreLoading} + > + {checkoutLoadingSlug ? 'Starting…' : 'Complete payment'} + + {native ? ( + + {restoreLoading ? 'Restoring…' : 'Restore purchases'} + + ) : null} + )} Refresh @@ -583,8 +760,12 @@ const BillingSection = (): JSX.Element => {
{planPickerMode === 'upgrade' - ? 'Choose a higher plan. Checkout opens securely in Stripe.' - : 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'} + ? native && !showStripeManage + ? 'Choose a higher plan. Purchase completes in the app store.' + : 'Choose a higher plan. Checkout opens securely in Stripe.' + : native && !showStripeManage + ? 'Select another plan to purchase via the app store.' + : 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'} {pickerPlans.map((plan) => ( @@ -624,6 +805,9 @@ const BillingSection = (): JSX.Element => {
) : null} + {actionNotice ? ( + {actionNotice} + ) : null} {actionError ? {actionError} : null} @@ -641,6 +825,7 @@ const BillingSection = (): JSX.Element => { Date + Provider Description Amount Status @@ -651,6 +836,9 @@ const BillingSection = (): JSX.Element => { {historyRows.map((row) => ( {formatBillingDate(row.date)} + + {row.provider} + {row.description} {row.amount} {row.status} @@ -685,9 +873,9 @@ const BillingSection = (): JSX.Element => { > Cancel subscription? - You will finish canceling in the Stripe customer portal. Access typically - continues until the end of the current billing period - {periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}. + {native && (storeSub || showStoreManage) && !showStripeManage + ? `You will cancel in ${storeLabel} subscription settings. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.` + : `You will finish canceling in the Stripe customer portal. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`} { onClick={handleConfirmCancel} disabled={portalLoading} > - {portalLoading ? 'Opening…' : 'Continue to cancel'} + {native && (storeSub || showStoreManage) && !showStripeManage + ? 'Got it' + : portalLoading + ? 'Opening…' + : 'Continue to cancel'} ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })); +jest.mock('../../utils/revenueCat', () => ({ + logOutRevenueCat: () => Promise.resolve(), +})); + const theme = { main: '#4a90e2', darkMode: true, diff --git a/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.tsx b/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.tsx index 6e4b85f..db0cadd 100644 --- a/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.tsx +++ b/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.tsx @@ -6,6 +6,7 @@ import { clearTokens, getRefreshToken } from '../../auth/tokenStorage'; import { AccountContext } from '../../contexts/AccountContext'; import { AuthContext } from '../../contexts/AuthContext'; import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; +import { logOutRevenueCat } from '../../utils/revenueCat'; const GlassCard = styled.div` background: ${({ theme }) => theme.colors.cardBackground}; @@ -180,6 +181,7 @@ const DeleteAccountSection = (): JSX.Element => { applyAccessToken(null); setAuthentication(false); setAccount(undefined); + void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err)); navigate('/signin/'); } catch (err: unknown) { trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED); diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx index eb11527..0eb01dd 100644 --- a/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx +++ b/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx @@ -19,6 +19,10 @@ jest.mock('../../../axiosApi', () => ({ applyAccessToken: jest.fn(), })); +jest.mock('../../utils/revenueCat', () => ({ + logOutRevenueCat: () => Promise.resolve(), +})); + const theme = { main: '#4a90e2', focus: '#224466', diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.tsx index 08c5410..80741f0 100644 --- a/llm-fe/src/llm-fe/components/Header2/Header2.tsx +++ b/llm-fe/src/llm-fe/components/Header2/Header2.tsx @@ -6,6 +6,7 @@ import { AccountContext } from '../../contexts/AccountContext'; import { applyAccessToken, axiosInstance } from '../../../axiosApi'; import { clearTokens, getRefreshToken } from '../../auth/tokenStorage'; import { useSubscription } from '../../hooks/useSubscription'; +import { logOutRevenueCat } from '../../utils/revenueCat'; import hesychiaMark from '../../assets/brand/hesychia-mark.png'; const HeaderContainer = styled.header` @@ -230,6 +231,7 @@ const Header2 = ({ applyAccessToken(null); setAuthentication(false) setAccount(undefined); + void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err)); navigate('/signin/') } catch (e) { console.error(e); diff --git a/llm-fe/src/llm-fe/contexts/AccountContext.tsx b/llm-fe/src/llm-fe/contexts/AccountContext.tsx index 47d4919..f646a5c 100644 --- a/llm-fe/src/llm-fe/contexts/AccountContext.tsx +++ b/llm-fe/src/llm-fe/contexts/AccountContext.tsx @@ -3,6 +3,7 @@ import { Account, AccountType } from "../data"; import { AuthContext } from "./AuthContext"; import { AxiosResponse } from "axios"; import { axiosInstance } from "../../axiosApi"; +import { logInRevenueCat } from "../utils/revenueCat"; type AccountProviderProps ={ children? : ReactNode; @@ -28,6 +29,7 @@ const AccountProvider = ({children}: AccountProviderProps) => { const get_user_response: AxiosResponse = await axiosInstance.get('/user/get/') const account: Account = new Account({ + id: get_user_response.data.id, email: get_user_response.data.email, first_name: get_user_response.data.first_name, last_name: get_user_response.data.last_name, @@ -43,6 +45,9 @@ const AccountProvider = ({children}: AccountProviderProps) => { }); setAccount(account); + void Promise.resolve(logInRevenueCat(account)).catch((err) => + console.warn('RevenueCat logIn', err) + ); } @@ -61,4 +66,4 @@ const AccountProvider = ({children}: AccountProviderProps) => { ) } -export { AccountContext, AccountProvider } \ No newline at end of file +export { AccountContext, AccountProvider } diff --git a/llm-fe/src/llm-fe/data.ts b/llm-fe/src/llm-fe/data.ts index 8f61fe3..848c5de 100644 --- a/llm-fe/src/llm-fe/data.ts +++ b/llm-fe/src/llm-fe/data.ts @@ -238,6 +238,7 @@ export class AdminAnalytics { } export interface AccountType { + id?: number; email: string; first_name: string; last_name: string; @@ -254,6 +255,7 @@ export interface PreferencesType { } export class Account { + id?: number; email: string = ''; first_name: string =''; last_name: string = ''; @@ -265,6 +267,7 @@ export class Account { has_signed_tos: boolean = false; constructor(initializer?: any){ if (!initializer) return; + if (initializer.id != null) this.id = Number(initializer.id); if (initializer.email) this.email = initializer.email; if (initializer.first_name) this.first_name = initializer.first_name; if (initializer.is_company_manager) this.is_company_manager = initializer.is_company_manager; diff --git a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.test.tsx b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.test.tsx index 4e0eb3e..cd3f52c 100644 --- a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.test.tsx +++ b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.test.tsx @@ -7,6 +7,11 @@ import { AccountContext } from '../../contexts/AccountContext'; jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); +jest.mock('../../utils/revenueCat', () => ({ + logInRevenueCat: () => Promise.resolve(), + purchasePlan: () => Promise.resolve(), +})); + const mockPost = jest.fn(); const mockGet = jest.fn(); const mockApplyAccessToken = jest.fn(); diff --git a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx index 035f035..a6ea380 100644 --- a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx +++ b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx @@ -10,6 +10,9 @@ import { AccountContext } from '../../contexts/AccountContext'; import { Account, AccountType } from '../../data'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; +import { checkoutReturnUrls } from '../../utils/finance'; +import { isNativePlatform } from '../../platform/nativePlatform'; +import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat'; const PageContainer = styled.div` position: relative; @@ -81,14 +84,6 @@ const NavLink = styled(Link)` } `; -function checkoutReturnUrls(): { success_url: string; cancel_url: string } { - const origin = window.location.origin; - return { - success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${origin}/billing/cancel`, - }; -} - const AuthCallback = (): JSX.Element => { const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext); const { setAccount } = useContext(AccountContext); @@ -142,6 +137,7 @@ const AuthCallback = (): JSX.Element => { } const account = new Account({ + id: get_user_response.data.id, email: get_user_response.data.email, first_name: get_user_response.data.first_name, last_name: get_user_response.data.last_name, @@ -165,8 +161,23 @@ const AuthCallback = (): JSX.Element => { trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' }); } identifyAccount(account); + void Promise.resolve(logInRevenueCat(account)).catch((err) => + console.warn('RevenueCat logIn', err) + ); if (needsCheckout) { + if (isNativePlatform()) { + setStatusText('Starting store purchase…'); + try { + trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup_native' }); + await purchasePlan(); + } catch (purchaseError) { + console.warn('RevenueCat purchase after SSO signup', purchaseError); + } + navigate('/account/', { replace: true }); + return; + } + setStatusText('Starting checkout…'); const { success_url, cancel_url } = checkoutReturnUrls(); trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' }); diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx index 8c978df..9608417 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx @@ -8,6 +8,10 @@ import { AccountContext } from '../../contexts/AccountContext'; jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); +jest.mock('../../utils/revenueCat', () => ({ + logInRevenueCat: () => Promise.resolve(), +})); + const mockPost = jest.fn(); const mockGet = jest.fn(); diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx index 8f0af9e..15396f8 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx @@ -10,6 +10,7 @@ import { Account, AccountType } from '../../data'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; +import { logInRevenueCat } from '../../utils/revenueCat'; import styled from 'styled-components'; import * as Yup from 'yup'; import hesychiaMark from '../../assets/brand/hesychia-mark.png'; @@ -221,6 +222,7 @@ const SignIn = (): JSX.Element => { const get_user_response: AxiosResponse = await axiosInstance.get('/user/get/') const account = new Account({ + id: get_user_response.data.id, email: get_user_response.data.email, first_name: get_user_response.data.first_name, last_name: get_user_response.data.last_name, @@ -240,6 +242,9 @@ const SignIn = (): JSX.Element => { setNeedsNewPassword(get_user_response.data.has_usable_password) trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' }); identifyAccount(account); + void Promise.resolve(logInRevenueCat(account)).catch((err) => + console.warn('RevenueCat logIn', err) + ); if (account.has_signed_tos) { navigate('/'); } else { diff --git a/llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx b/llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx index 6e780f4..b0a3986 100644 --- a/llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx +++ b/llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx @@ -8,6 +8,11 @@ import { AccountContext } from '../../contexts/AccountContext'; jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); +jest.mock('../../utils/revenueCat', () => ({ + logInRevenueCat: () => Promise.resolve(), + purchasePlan: () => Promise.resolve(), +})); + const mockPost = jest.fn(); const mockGet = jest.fn(); const assignMock = jest.fn(); diff --git a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx index 16db37b..b32df98 100644 --- a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx +++ b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx @@ -10,6 +10,9 @@ import { Account, AccountType } from '../../data'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; +import { checkoutReturnUrls } from '../../utils/finance'; +import { isNativePlatform } from '../../platform/nativePlatform'; +import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat'; import styled from 'styled-components'; import * as Yup from 'yup'; import hesychiaMark from '../../assets/brand/hesychia-mark.png'; @@ -170,14 +173,6 @@ const validationSchema = Yup.object().shape({ company_name: Yup.string(), }); -function checkoutReturnUrls(): { success_url: string; cancel_url: string } { - const origin = window.location.origin; - return { - success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${origin}/billing/cancel`, - }; -} - const SignUp = (): JSX.Element => { const navigate = useNavigate(); const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext); @@ -213,6 +208,7 @@ const SignUp = (): JSX.Element => { const loadAccount = async (): Promise => { const get_user_response: AxiosResponse = await axiosInstance.get('/user/get/'); const account = new Account({ + id: get_user_response.data.id, email: get_user_response.data.email, first_name: get_user_response.data.first_name, last_name: get_user_response.data.last_name, @@ -249,14 +245,31 @@ const SignUp = (): JSX.Element => { const account = await loadAccount(); trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' }); identifyAccount(account); + void Promise.resolve(logInRevenueCat(account)).catch((err) => + console.warn('RevenueCat logIn', err) + ); - const { success_url, cancel_url } = checkoutReturnUrls(); const needsCheckout = registerResponse.data?.needs_checkout !== false; if (!needsCheckout) { navigate('/'); return; } + // Native: store IAP via RevenueCat (BillingSection) — skip Stripe Checkout. + if (isNativePlatform()) { + try { + trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup_native' }); + await purchasePlan(); + navigate('/account/'); + return; + } catch (purchaseError: unknown) { + console.warn('RevenueCat purchase after signup', purchaseError); + navigate('/account/'); + return; + } + } + + const { success_url, cancel_url } = checkoutReturnUrls(); trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' }); const checkoutResponse = await axiosInstance.post('/finance/checkout/', { success_url, diff --git a/llm-fe/src/llm-fe/utils/finance.test.ts b/llm-fe/src/llm-fe/utils/finance.test.ts index 3fc62ed..b045969 100644 --- a/llm-fe/src/llm-fe/utils/finance.test.ts +++ b/llm-fe/src/llm-fe/utils/finance.test.ts @@ -3,6 +3,10 @@ import { formatMoneyCents, formatTokenCount, humanizeStatus, + invoiceProviderLabel, + isComplimentarySubscription, + isStoreSubscription, + isStripeSubscription, pickPrimaryInvoice, planAllowsRag, } from './finance'; @@ -92,9 +96,44 @@ describe('finance helpers', () => { expect(pickPrimaryInvoice(invoices)?.id).toBe(2); }); - it('detects portal access from paid or subscribed invoices', () => { + it('detects portal access from paid Stripe invoices only', () => { expect(canOpenBillingPortal([baseInvoice({ status: 'open' })])).toBe(false); expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true); + expect( + canOpenBillingPortal([ + baseInvoice({ + status: 'paid', + provider: 'revenuecat', + stripe_subscription_id: null, + revenuecat_store: 'PLAY_STORE', + }), + ]) + ).toBe(false); + }); + + it('labels invoice providers for history', () => { + expect(invoiceProviderLabel(baseInvoice({ provider: 'stripe' }))).toBe('Stripe'); + expect( + invoiceProviderLabel( + baseInvoice({ provider: 'revenuecat', revenuecat_store: 'PLAY_STORE' }) + ) + ).toBe('Play Store'); + expect( + invoiceProviderLabel( + baseInvoice({ provider: 'revenuecat', revenuecat_store: 'APP_STORE' }) + ) + ).toBe('App Store'); + }); + + it('classifies subscription sources', () => { + expect(isStoreSubscription('revenuecat')).toBe(true); + expect(isStripeSubscription('stripe')).toBe(true); + expect( + isComplimentarySubscription( + { source: 'revenuecat', needs_checkout: false }, + false + ) + ).toBe(false); }); }); diff --git a/llm-fe/src/llm-fe/utils/finance.ts b/llm-fe/src/llm-fe/utils/finance.ts index ce07ab9..33d407b 100644 --- a/llm-fe/src/llm-fe/utils/finance.ts +++ b/llm-fe/src/llm-fe/utils/finance.ts @@ -10,6 +10,8 @@ export type FinanceInvoice = { stripe_invoice_id: string | null; stripe_checkout_session_id: string | null; stripe_subscription_id: string | null; + revenuecat_event_id?: string | null; + revenuecat_store?: string | null; hosted_invoice_url: string; description: string; created: string; @@ -25,6 +27,7 @@ export type FinancePayment = { amount: number; stripe_payment_intent_id: string | null; stripe_charge_id: string | null; + revenuecat_transaction_id?: string | null; paid_at: string | null; failure_message: string; created: string; @@ -75,12 +78,61 @@ export type SubscriptionMe = { source: string; needs_checkout: boolean; stripe_subscription_id: string; + revenuecat_original_transaction_id?: string; cancel_at_period_end?: boolean; current_period_end?: string | null; usage: SubscriptionUsage; }; -export type SubscriptionSource = 'none' | 'stripe' | 'backer' | 'admin' | string; +export type SubscriptionSource = + | 'none' + | 'stripe' + | 'revenuecat' + | 'backer' + | 'admin' + | string; + +export function isStoreSubscription( + source: string | null | undefined +): boolean { + return source === 'revenuecat'; +} + +export function isStripeSubscription( + source: string | null | undefined +): boolean { + return source === 'stripe'; +} + +/** Human label for invoice provider / store (history badge). */ +export function invoiceProviderLabel( + invoice: Pick +): string { + const provider = (invoice.provider || '').toLowerCase(); + if (provider === 'revenuecat') { + const store = (invoice.revenuecat_store || '').toUpperCase(); + if ( + store.includes('PLAY') || + store === 'GOOGLE' || + store === 'GOOGLE_PLAY' || + store === 'PLAY_STORE' + ) { + return 'Play Store'; + } + if ( + store.includes('APP_STORE') || + store.includes('MAC') || + store === 'APPLE' || + store === 'APP_STORE' + ) { + return 'App Store'; + } + if (store) return store; + return 'Store'; + } + if (provider === 'stripe') return 'Stripe'; + return invoice.provider || '—'; +} /** Complimentary / admin-granted access — no Stripe cancel/change. */ export function isComplimentarySubscription( @@ -89,6 +141,13 @@ export function isComplimentarySubscription( ): boolean { if (!subscription) return false; if (subscription.source === 'backer' || subscription.source === 'admin') return true; + // Paid Stripe / store entitlements are not complimentary even without portal rows. + if ( + isStripeSubscription(subscription.source) || + isStoreSubscription(subscription.source) + ) { + return false; + } return !hasPortalAccess && subscription.needs_checkout === false; } @@ -171,9 +230,12 @@ export function pickPrimaryInvoice(invoices: FinanceInvoice[]): FinanceInvoice | return invoices[0]; } +/** Stripe customer portal only — ignore RevenueCat / store invoices. */ export function canOpenBillingPortal(invoices: FinanceInvoice[]): boolean { return invoices.some( - (inv) => Boolean(inv.stripe_subscription_id) || inv.status === 'paid' + (inv) => + (inv.provider || 'stripe').toLowerCase() === 'stripe' && + (Boolean(inv.stripe_subscription_id) || inv.status === 'paid') ); } diff --git a/llm-fe/src/llm-fe/utils/revenueCat.test.ts b/llm-fe/src/llm-fe/utils/revenueCat.test.ts new file mode 100644 index 0000000..c38065b --- /dev/null +++ b/llm-fe/src/llm-fe/utils/revenueCat.test.ts @@ -0,0 +1,49 @@ +jest.mock('@revenuecat/purchases-capacitor', () => ({ + Purchases: { + configure: jest.fn(), + logIn: jest.fn(), + logOut: jest.fn(), + getOfferings: jest.fn(), + purchasePackage: jest.fn(), + restorePurchases: jest.fn(), + }, +})); + +import { + resolveAppUserId, + isPurchaseCancelledError, +} from './revenueCat'; +import { ACCESS_TOKEN_KEY } from '../auth/tokenStorage'; + +function makeJwt(payload: Record): string { + const body = btoa(JSON.stringify(payload)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `hdr.${body}.sig`; +} + +describe('revenueCat helpers', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('prefers account.id for appUserID', () => { + expect(resolveAppUserId({ id: 42 })).toBe('42'); + }); + + it('falls back to JWT user_id claim', () => { + localStorage.setItem(ACCESS_TOKEN_KEY, makeJwt({ user_id: 99, exp: 9999999999 })); + expect(resolveAppUserId(null)).toBe('99'); + }); + + it('returns null when no id available', () => { + expect(resolveAppUserId(undefined)).toBeNull(); + }); + + it('detects cancelled purchase errors', () => { + expect(isPurchaseCancelledError({ userCancelled: true })).toBe(true); + expect(isPurchaseCancelledError({ code: 1 })).toBe(true); + expect(isPurchaseCancelledError({ message: 'boom' })).toBe(false); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/revenueCat.ts b/llm-fe/src/llm-fe/utils/revenueCat.ts new file mode 100644 index 0000000..86469ee --- /dev/null +++ b/llm-fe/src/llm-fe/utils/revenueCat.ts @@ -0,0 +1,176 @@ +/** + * RevenueCat Capacitor IAP wrapper (#100). + * No-ops on web; only configures / purchases on native platforms. + */ +import { Purchases } from '@revenuecat/purchases-capacitor'; +import type { PurchasesPackage } from '@revenuecat/purchases-capacitor'; +import { decodeJwtPayload } from '../auth/jwtHelpers'; +import { getAccessToken } from '../auth/tokenStorage'; +import { isNativePlatform } from '../platform/nativePlatform'; + +let configurePromise: Promise | null = null; +let configured = false; + +type AccountLike = { id?: number | string | null }; + +function getCapacitorPlatform(): string { + if (typeof window === 'undefined') return 'web'; + const Cap = window.Capacitor as + | { getPlatform?: () => string; isNativePlatform?: () => boolean } + | undefined; + if (typeof Cap?.getPlatform === 'function') { + return Cap.getPlatform(); + } + return 'web'; +} + +function revenueCatApiKey(): string { + const platform = getCapacitorPlatform(); + if (platform === 'ios') { + return (process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim(); + } + if (platform === 'android') { + return (process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim(); + } + // Fallback: prefer Google then Apple if platform unknown on native. + return ( + (process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim() || + (process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim() + ); +} + +/** Prefer numeric user pk; fall back to JWT `user_id` claim. */ +export function resolveAppUserId(account?: AccountLike | null): string | null { + if (account?.id != null && String(account.id).trim() !== '') { + return String(account.id); + } + const token = getAccessToken(); + if (!token) return null; + const payload = decodeJwtPayload(token) as { user_id?: number | string } | null; + if (payload?.user_id != null && String(payload.user_id).trim() !== '') { + return String(payload.user_id); + } + return null; +} + +export async function configureRevenueCat(): Promise { + if (!isNativePlatform()) return false; + if (configured) return true; + if (configurePromise) { + await configurePromise; + return configured; + } + + configurePromise = (async () => { + const apiKey = revenueCatApiKey(); + if (!apiKey) { + console.warn( + 'RevenueCat API key missing (REACT_APP_REVENUECAT_APPLE_API_KEY / GOOGLE).' + ); + return; + } + await Purchases.configure({ apiKey }); + configured = true; + })(); + + try { + await configurePromise; + } finally { + configurePromise = null; + } + return configured; +} + +export async function logInRevenueCat( + appUserIdOrAccount?: string | AccountLike | null +): Promise { + if (!isNativePlatform()) return; + const appUserID = + typeof appUserIdOrAccount === 'string' + ? appUserIdOrAccount + : resolveAppUserId(appUserIdOrAccount); + if (!appUserID) { + console.warn('RevenueCat logIn skipped: no app user id'); + return; + } + const ready = await configureRevenueCat(); + if (!ready) return; + await Purchases.logIn({ appUserID }); +} + +export async function logOutRevenueCat(): Promise { + if (!isNativePlatform()) return; + if (!configured) return; + try { + await Purchases.logOut(); + } catch (error) { + // Anonymous / already logged out — ignore. + console.warn('RevenueCat logOut', error); + } +} + +function packageMatchesPlan(pkg: PurchasesPackage, planSlug: string): boolean { + const slug = planSlug.toLowerCase(); + const id = (pkg.identifier || '').toLowerCase(); + const productId = (pkg.product?.identifier || '').toLowerCase(); + return id === slug || id.includes(slug) || productId.includes(slug); +} + +async function resolvePackageForPlan( + planSlug?: string +): Promise { + const offerings = await Purchases.getOfferings(); + const offeringId = (process.env.REACT_APP_REVENUECAT_OFFERING_ID || '').trim(); + const offering = + (offeringId && offerings.all?.[offeringId]) || offerings.current || null; + if (!offering) { + throw new Error('No RevenueCat offerings available. Check the RC dashboard.'); + } + + const packages = offering.availablePackages || []; + if (!packages.length) { + throw new Error('RevenueCat offering has no packages.'); + } + + if (planSlug) { + const match = packages.find((pkg) => packageMatchesPlan(pkg, planSlug)); + if (match) return match; + throw new Error(`No store package matches plan "${planSlug}".`); + } + + return ( + offering.monthly || + packages.find((pkg) => (pkg.identifier || '').includes('monthly')) || + packages[0] + ); +} + +export async function purchasePlan(planSlug?: string): Promise { + if (!isNativePlatform()) { + throw new Error('Store purchases are only available in the mobile app.'); + } + const ready = await configureRevenueCat(); + if (!ready) { + throw new Error('RevenueCat is not configured. Missing API key.'); + } + const aPackage = await resolvePackageForPlan(planSlug); + await Purchases.purchasePackage({ aPackage }); +} + +export async function restorePurchases(): Promise { + if (!isNativePlatform()) { + throw new Error('Restore is only available in the mobile app.'); + } + const ready = await configureRevenueCat(); + if (!ready) { + throw new Error('RevenueCat is not configured. Missing API key.'); + } + await Purchases.restorePurchases(); +} + +export function isPurchaseCancelledError(error: unknown): boolean { + const err = error as { userCancelled?: boolean; code?: number | string }; + if (err?.userCancelled === true) return true; + // PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR === 1 + return err?.code === 1 || err?.code === '1' || err?.code === 'PURCHASE_CANCELLED'; +} diff --git a/llm-fe/src/react-app-env.d.ts b/llm-fe/src/react-app-env.d.ts index dd46596..bff69ee 100644 --- a/llm-fe/src/react-app-env.d.ts +++ b/llm-fe/src/react-app-env.d.ts @@ -9,6 +9,7 @@ interface CapacitorPreferencesPlugin { interface CapacitorBridge { isNativePlatform?: () => boolean; isNative?: boolean; + getPlatform?: () => string; Plugins?: { Preferences?: CapacitorPreferencesPlugin; App?: { addListener?: (...args: unknown[]) => unknown };