1 Commits
Author SHA1 Message Date
westfarn 0969629122 Add RevenueCat Capacitor IAP; keep Stripe for web billing.
Unit Tests / test (pull_request) Failing after 11s
Native builds purchase/restore via RevenueCat; browser keeps Stripe
checkout/portal. Billing history shows store ledger rows from backend
webhooks. Closes #100.
2026-08-03 14:57:50 -05:00
24 changed files with 762 additions and 38 deletions
+6
View File
@@ -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=
+14
View File
@@ -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`:
+40
View File
@@ -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
+19
View File
@@ -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",
+1
View File
@@ -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",
@@ -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();
});
});
@@ -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<FinanceInvoice[]>([]);
const [payments, setPayments] = useState<FinancePayment[]>([]);
const [subscription, setSubscription] = useState<SubscriptionMe | null>(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<string | null>(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 && (
<ButtonRow>
{hasPortalAccess && !complimentary ? (
{showStripeManage ? (
<>
<StyledButton
type="button"
@@ -564,14 +681,74 @@ const BillingSection = (): JSX.Element => {
Complimentary access no payment required. Plan changes and
cancellation are not available for this account.
</BodyText>
) : showStoreManage ? (
<>
<StyledButton
type="button"
onClick={handleUpgradeClick}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
Upgrade
</StyledButton>
<SecondaryButton
type="button"
onClick={handleChangePlanClick}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
Change plan
</SecondaryButton>
{!subscription?.cancel_at_period_end &&
subscription?.status !== 'canceled' ? (
<DangerButton
type="button"
onClick={() => setCancelConfirmOpen(true)}
disabled={Boolean(checkoutLoadingSlug)}
>
Cancel
</DangerButton>
) : null}
<SecondaryButton
type="button"
onClick={() =>
setActionNotice(
`Manage billing in ${storeLabel} → Subscriptions. Changes sync here after refresh.`
)
}
data-testid="store-manage-hint"
>
Manage in {storeLabel}
</SecondaryButton>
{native ? (
<SecondaryButton
type="button"
onClick={handleRestore}
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
data-testid="restore-purchases"
>
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
</SecondaryButton>
) : null}
</>
) : (
<>
<StyledButton
type="button"
onClick={() => handleStartCheckout()}
disabled={Boolean(checkoutLoadingSlug)}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
</StyledButton>
{native ? (
<SecondaryButton
type="button"
onClick={handleRestore}
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
data-testid="restore-purchases"
>
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
</SecondaryButton>
) : null}
</>
)}
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
Refresh
@@ -583,7 +760,11 @@ const BillingSection = (): JSX.Element => {
<div data-testid="plan-picker">
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
{planPickerMode === 'upgrade'
? 'Choose a higher plan. Checkout opens securely in Stripe.'
? 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).'}
</BodyText>
<PlanList>
@@ -624,6 +805,9 @@ const BillingSection = (): JSX.Element => {
</div>
) : null}
{actionNotice ? (
<NoticeText data-testid="billing-action-notice">{actionNotice}</NoticeText>
) : null}
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
</GlassCard>
@@ -641,6 +825,7 @@ const BillingSection = (): JSX.Element => {
<thead>
<tr>
<Th>Date</Th>
<Th>Provider</Th>
<Th>Description</Th>
<Th>Amount</Th>
<Th>Status</Th>
@@ -651,6 +836,9 @@ const BillingSection = (): JSX.Element => {
{historyRows.map((row) => (
<tr key={row.key}>
<Td>{formatBillingDate(row.date)}</Td>
<Td>
<ProviderBadge>{row.provider}</ProviderBadge>
</Td>
<Td>{row.description}</Td>
<Td>{row.amount}</Td>
<Td>{row.status}</Td>
@@ -685,9 +873,9 @@ const BillingSection = (): JSX.Element => {
>
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
<BodyText>
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})` : ''}.`}
</BodyText>
<ButtonRow>
<DangerButton
@@ -695,7 +883,11 @@ const BillingSection = (): JSX.Element => {
onClick={handleConfirmCancel}
disabled={portalLoading}
>
{portalLoading ? 'Opening…' : 'Continue to cancel'}
{native && (storeSub || showStoreManage) && !showStripeManage
? 'Got it'
: portalLoading
? 'Opening…'
: 'Continue to cancel'}
</DangerButton>
<SecondaryButton
type="button"
@@ -44,6 +44,10 @@ jest.mock('../../utils/analytics', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}));
jest.mock('../../utils/revenueCat', () => ({
logOutRevenueCat: () => Promise.resolve(),
}));
const theme = {
main: '#4a90e2',
darkMode: true,
@@ -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);
@@ -19,6 +19,10 @@ jest.mock('../../../axiosApi', () => ({
applyAccessToken: jest.fn(),
}));
jest.mock('../../utils/revenueCat', () => ({
logOutRevenueCat: () => Promise.resolve(),
}));
const theme = {
main: '#4a90e2',
focus: '#224466',
@@ -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);
@@ -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<AccountType> = 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)
);
}
+3
View File
@@ -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;
@@ -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();
@@ -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' });
@@ -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();
@@ -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<AccountType> = 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 {
@@ -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();
+22 -9
View File
@@ -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<Account> => {
const get_user_response: AxiosResponse<AccountType> = 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,
+40 -1
View File
@@ -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);
});
});
+64 -2
View File
@@ -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<FinanceInvoice, 'provider' | 'revenuecat_store'>
): 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')
);
}
@@ -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, unknown>): 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);
});
});
+176
View File
@@ -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<void> | 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<boolean> {
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<void> {
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<void> {
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<PurchasesPackage> {
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<void> {
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<void> {
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';
}
+1
View File
@@ -9,6 +9,7 @@ interface CapacitorPreferencesPlugin {
interface CapacitorBridge {
isNativePlatform?: () => boolean;
isNative?: boolean;
getPlatform?: () => string;
Plugins?: {
Preferences?: CapacitorPreferencesPlugin;
App?: { addListener?: (...args: unknown[]) => unknown };