Compare commits
1
Commits
master
...
75a0d7f637
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a0d7f637 |
@@ -4,3 +4,9 @@
|
|||||||
# REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
|
# 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_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
|
||||||
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
|
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=
|
||||||
|
|||||||
@@ -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`.
|
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
|
## Versioning
|
||||||
|
|
||||||
In `android/app/build.gradle`:
|
In `android/app/build.gradle`:
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+19
@@ -21,6 +21,7 @@
|
|||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/icons-material": "^5.16.11",
|
"@mui/icons-material": "^5.16.11",
|
||||||
"@mui/material": "^5.16.11",
|
"@mui/material": "^5.16.11",
|
||||||
|
"@revenuecat/purchases-capacitor": "^11.3.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
@@ -5239,6 +5240,24 @@
|
|||||||
"react": ">=16.14.0"
|
"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": {
|
"node_modules/@rollup/plugin-babel": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/icons-material": "^5.16.11",
|
"@mui/icons-material": "^5.16.11",
|
||||||
"@mui/material": "^5.16.11",
|
"@mui/material": "^5.16.11",
|
||||||
|
"@revenuecat/purchases-capacitor": "^11.3.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const mockGet = jest.fn();
|
|||||||
const mockPost = jest.fn();
|
const mockPost = jest.fn();
|
||||||
const assignMock = jest.fn();
|
const assignMock = jest.fn();
|
||||||
const mockTrackEvent = jest.fn();
|
const mockTrackEvent = jest.fn();
|
||||||
|
const mockPurchasePlan = jest.fn();
|
||||||
|
const mockRestorePurchases = jest.fn();
|
||||||
|
const mockIsNativePlatform = jest.fn(() => false);
|
||||||
|
|
||||||
jest.mock('../../../axiosApi', () => ({
|
jest.mock('../../../axiosApi', () => ({
|
||||||
axiosInstance: {
|
axiosInstance: {
|
||||||
@@ -27,6 +30,17 @@ jest.mock('../../utils/analytics', () => ({
|
|||||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
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 = {
|
const theme = {
|
||||||
main: '#4a90e2',
|
main: '#4a90e2',
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
@@ -174,6 +188,9 @@ describe('BillingSection', () => {
|
|||||||
mockPost.mockReset();
|
mockPost.mockReset();
|
||||||
assignMock.mockReset();
|
assignMock.mockReset();
|
||||||
mockTrackEvent.mockReset();
|
mockTrackEvent.mockReset();
|
||||||
|
mockPurchasePlan.mockReset();
|
||||||
|
mockRestorePurchases.mockReset();
|
||||||
|
mockIsNativePlatform.mockReturnValue(false);
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -345,4 +362,49 @@ describe('BillingSection', () => {
|
|||||||
expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument();
|
expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument();
|
||||||
expect(assignMock).not.toHaveBeenCalled();
|
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,
|
formatTokenCount,
|
||||||
higherSelectablePlans,
|
higherSelectablePlans,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
|
invoiceProviderLabel,
|
||||||
isComplimentarySubscription,
|
isComplimentarySubscription,
|
||||||
|
isStoreSubscription,
|
||||||
|
isStripeSubscription,
|
||||||
otherSelectablePlans,
|
otherSelectablePlans,
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
SubscriptionMe,
|
SubscriptionMe,
|
||||||
SubscriptionPlanInfo,
|
SubscriptionPlanInfo,
|
||||||
} from '../../utils/finance';
|
} from '../../utils/finance';
|
||||||
|
import { isNativePlatform } from '../../platform/nativePlatform';
|
||||||
|
import {
|
||||||
|
isPurchaseCancelledError,
|
||||||
|
purchasePlan,
|
||||||
|
restorePurchases,
|
||||||
|
} from '../../utils/revenueCat';
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
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 {
|
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||||
const axiosError = error as {
|
const axiosError = error as {
|
||||||
response?: { data?: { detail?: string } };
|
response?: { data?: { detail?: string } };
|
||||||
@@ -241,9 +258,19 @@ function apiErrorMessage(error: unknown, fallback: string): string {
|
|||||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
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';
|
type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel';
|
||||||
|
|
||||||
const BillingSection = (): JSX.Element => {
|
const BillingSection = (): JSX.Element => {
|
||||||
|
const native = isNativePlatform();
|
||||||
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
||||||
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
||||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
@@ -251,8 +278,10 @@ const BillingSection = (): JSX.Element => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [listError, setListError] = useState('');
|
const [listError, setListError] = useState('');
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
|
const [actionNotice, setActionNotice] = useState('');
|
||||||
const [portalLoading, setPortalLoading] = useState(false);
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
|
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
|
||||||
|
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||||
const [showPlanPicker, setShowPlanPicker] = useState(false);
|
const [showPlanPicker, setShowPlanPicker] = useState(false);
|
||||||
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
|
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
|
||||||
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||||
@@ -293,6 +322,16 @@ const BillingSection = (): JSX.Element => {
|
|||||||
() => isComplimentarySubscription(subscription, hasPortalAccess),
|
() => isComplimentarySubscription(subscription, hasPortalAccess),
|
||||||
[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(
|
const upgradePlans = useMemo(
|
||||||
() => higherSelectablePlans(plans, subscription?.plan),
|
() => higherSelectablePlans(plans, subscription?.plan),
|
||||||
[plans, subscription?.plan]
|
[plans, subscription?.plan]
|
||||||
@@ -306,6 +345,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
subscription?.current_period_end || primaryInvoice?.period_end || null;
|
subscription?.current_period_end || primaryInvoice?.period_end || null;
|
||||||
return formatBillingDate(end);
|
return formatBillingDate(end);
|
||||||
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
|
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
|
||||||
|
const storeLabel = useMemo(() => storeManageLabel(), []);
|
||||||
|
|
||||||
const historyRows = useMemo(() => {
|
const historyRows = useMemo(() => {
|
||||||
if (invoices.length) {
|
if (invoices.length) {
|
||||||
@@ -313,6 +353,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
key: `invoice-${invoice.id}`,
|
key: `invoice-${invoice.id}`,
|
||||||
date: invoice.created,
|
date: invoice.created,
|
||||||
description: invoice.description || 'Invoice',
|
description: invoice.description || 'Invoice',
|
||||||
|
provider: invoiceProviderLabel(invoice),
|
||||||
amount: formatMoneyCents(
|
amount: formatMoneyCents(
|
||||||
invoice.amount_paid || invoice.amount_due,
|
invoice.amount_paid || invoice.amount_due,
|
||||||
invoice.currency
|
invoice.currency
|
||||||
@@ -325,6 +366,12 @@ const BillingSection = (): JSX.Element => {
|
|||||||
key: `payment-${payment.id}`,
|
key: `payment-${payment.id}`,
|
||||||
date: payment.paid_at || payment.created,
|
date: payment.paid_at || payment.created,
|
||||||
description: 'Payment',
|
description: 'Payment',
|
||||||
|
provider:
|
||||||
|
(payment.provider || '').toLowerCase() === 'revenuecat'
|
||||||
|
? 'Store'
|
||||||
|
: (payment.provider || '').toLowerCase() === 'stripe'
|
||||||
|
? 'Stripe'
|
||||||
|
: payment.provider || '—',
|
||||||
amount: formatMoneyCents(payment.amount, payment.currency),
|
amount: formatMoneyCents(payment.amount, payment.currency),
|
||||||
status: humanizeStatus(payment.status),
|
status: humanizeStatus(payment.status),
|
||||||
url: '',
|
url: '',
|
||||||
@@ -333,6 +380,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
const openPortal = async (intent: PortalIntent) => {
|
const openPortal = async (intent: PortalIntent) => {
|
||||||
setActionError('');
|
setActionError('');
|
||||||
|
setActionNotice('');
|
||||||
setPortalLoading(true);
|
setPortalLoading(true);
|
||||||
try {
|
try {
|
||||||
const returnUrl = `${window.location.origin}/account/`;
|
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('');
|
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__');
|
setCheckoutLoadingSlug(planSlug || '__default__');
|
||||||
try {
|
try {
|
||||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||||
@@ -395,6 +490,10 @@ const BillingSection = (): JSX.Element => {
|
|||||||
setShowPlanPicker(true);
|
setShowPlanPicker(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (native && (storeSub || showStoreManage)) {
|
||||||
|
void handleNativePurchase(undefined, 'account_upgrade');
|
||||||
|
return;
|
||||||
|
}
|
||||||
void openPortal('upgrade');
|
void openPortal('upgrade');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,10 +504,24 @@ const BillingSection = (): JSX.Element => {
|
|||||||
setShowPlanPicker(true);
|
setShowPlanPicker(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (native && (storeSub || showStoreManage)) {
|
||||||
|
setActionNotice(
|
||||||
|
`Change or cancel your plan in ${storeLabel} subscription settings.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void openPortal('change');
|
void openPortal('change');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmCancel = async () => {
|
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' });
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' });
|
||||||
setCancelConfirmOpen(false);
|
setCancelConfirmOpen(false);
|
||||||
await openPortal('cancel');
|
await openPortal('cancel');
|
||||||
@@ -422,7 +535,6 @@ const BillingSection = (): JSX.Element => {
|
|||||||
source: 'plan_picker',
|
source: 'plan_picker',
|
||||||
plan_slug: plan.slug,
|
plan_slug: plan.slug,
|
||||||
});
|
});
|
||||||
// New higher tier via Checkout when selectable; portal otherwise.
|
|
||||||
await handleStartCheckout(plan.slug, 'account_upgrade');
|
await handleStartCheckout(plan.slug, 'account_upgrade');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -430,7 +542,12 @@ const BillingSection = (): JSX.Element => {
|
|||||||
source: 'plan_picker',
|
source: 'plan_picker',
|
||||||
plan_slug: plan.slug,
|
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);
|
setShowPlanPicker(false);
|
||||||
await openPortal('change');
|
await openPortal('change');
|
||||||
};
|
};
|
||||||
@@ -525,7 +642,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
{!loading && !listError && (
|
{!loading && !listError && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
{hasPortalAccess && !complimentary ? (
|
{showStripeManage ? (
|
||||||
<>
|
<>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -564,14 +681,74 @@ const BillingSection = (): JSX.Element => {
|
|||||||
Complimentary access — no payment required. Plan changes and
|
Complimentary access — no payment required. Plan changes and
|
||||||
cancellation are not available for this account.
|
cancellation are not available for this account.
|
||||||
</BodyText>
|
</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"
|
<StyledButton
|
||||||
onClick={() => handleStartCheckout()}
|
type="button"
|
||||||
disabled={Boolean(checkoutLoadingSlug)}
|
onClick={() => handleStartCheckout()}
|
||||||
>
|
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
|
||||||
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
>
|
||||||
</StyledButton>
|
{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}>
|
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
|
||||||
Refresh
|
Refresh
|
||||||
@@ -583,8 +760,12 @@ const BillingSection = (): JSX.Element => {
|
|||||||
<div data-testid="plan-picker">
|
<div data-testid="plan-picker">
|
||||||
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
||||||
{planPickerMode === 'upgrade'
|
{planPickerMode === 'upgrade'
|
||||||
? 'Choose a higher plan. Checkout opens securely in Stripe.'
|
? native && !showStripeManage
|
||||||
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
|
? '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>
|
</BodyText>
|
||||||
<PlanList>
|
<PlanList>
|
||||||
{pickerPlans.map((plan) => (
|
{pickerPlans.map((plan) => (
|
||||||
@@ -624,6 +805,9 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{actionNotice ? (
|
||||||
|
<NoticeText data-testid="billing-action-notice">{actionNotice}</NoticeText>
|
||||||
|
) : null}
|
||||||
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
@@ -641,6 +825,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>Date</Th>
|
<Th>Date</Th>
|
||||||
|
<Th>Provider</Th>
|
||||||
<Th>Description</Th>
|
<Th>Description</Th>
|
||||||
<Th>Amount</Th>
|
<Th>Amount</Th>
|
||||||
<Th>Status</Th>
|
<Th>Status</Th>
|
||||||
@@ -651,6 +836,9 @@ const BillingSection = (): JSX.Element => {
|
|||||||
{historyRows.map((row) => (
|
{historyRows.map((row) => (
|
||||||
<tr key={row.key}>
|
<tr key={row.key}>
|
||||||
<Td>{formatBillingDate(row.date)}</Td>
|
<Td>{formatBillingDate(row.date)}</Td>
|
||||||
|
<Td>
|
||||||
|
<ProviderBadge>{row.provider}</ProviderBadge>
|
||||||
|
</Td>
|
||||||
<Td>{row.description}</Td>
|
<Td>{row.description}</Td>
|
||||||
<Td>{row.amount}</Td>
|
<Td>{row.amount}</Td>
|
||||||
<Td>{row.status}</Td>
|
<Td>{row.status}</Td>
|
||||||
@@ -685,9 +873,9 @@ const BillingSection = (): JSX.Element => {
|
|||||||
>
|
>
|
||||||
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
|
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
|
||||||
<BodyText>
|
<BodyText>
|
||||||
You will finish canceling in the Stripe customer portal. Access typically
|
{native && (storeSub || showStoreManage) && !showStripeManage
|
||||||
continues until the end of the current billing period
|
? `You will cancel in ${storeLabel} subscription settings. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`
|
||||||
{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>
|
</BodyText>
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<DangerButton
|
<DangerButton
|
||||||
@@ -695,7 +883,11 @@ const BillingSection = (): JSX.Element => {
|
|||||||
onClick={handleConfirmCancel}
|
onClick={handleConfirmCancel}
|
||||||
disabled={portalLoading}
|
disabled={portalLoading}
|
||||||
>
|
>
|
||||||
{portalLoading ? 'Opening…' : 'Continue to cancel'}
|
{native && (storeSub || showStoreManage) && !showStripeManage
|
||||||
|
? 'Got it'
|
||||||
|
: portalLoading
|
||||||
|
? 'Opening…'
|
||||||
|
: 'Continue to cancel'}
|
||||||
</DangerButton>
|
</DangerButton>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ jest.mock('../../utils/analytics', () => ({
|
|||||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../utils/revenueCat', () => ({
|
||||||
|
logOutRevenueCat: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
main: '#4a90e2',
|
main: '#4a90e2',
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
|||||||
import { AccountContext } from '../../contexts/AccountContext';
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||||
|
import { logOutRevenueCat } from '../../utils/revenueCat';
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
@@ -180,6 +181,7 @@ const DeleteAccountSection = (): JSX.Element => {
|
|||||||
applyAccessToken(null);
|
applyAccessToken(null);
|
||||||
setAuthentication(false);
|
setAuthentication(false);
|
||||||
setAccount(undefined);
|
setAccount(undefined);
|
||||||
|
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
|
||||||
navigate('/signin/');
|
navigate('/signin/');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
|
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ jest.mock('../../../axiosApi', () => ({
|
|||||||
applyAccessToken: jest.fn(),
|
applyAccessToken: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../utils/revenueCat', () => ({
|
||||||
|
logOutRevenueCat: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
main: '#4a90e2',
|
main: '#4a90e2',
|
||||||
focus: '#224466',
|
focus: '#224466',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AccountContext } from '../../contexts/AccountContext';
|
|||||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||||
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||||
import { useSubscription } from '../../hooks/useSubscription';
|
import { useSubscription } from '../../hooks/useSubscription';
|
||||||
|
import { logOutRevenueCat } from '../../utils/revenueCat';
|
||||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
||||||
|
|
||||||
const HeaderContainer = styled.header`
|
const HeaderContainer = styled.header`
|
||||||
@@ -230,6 +231,7 @@ const Header2 = ({
|
|||||||
applyAccessToken(null);
|
applyAccessToken(null);
|
||||||
setAuthentication(false)
|
setAuthentication(false)
|
||||||
setAccount(undefined);
|
setAccount(undefined);
|
||||||
|
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
|
||||||
navigate('/signin/')
|
navigate('/signin/')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Account, AccountType } from "../data";
|
|||||||
import { AuthContext } from "./AuthContext";
|
import { AuthContext } from "./AuthContext";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { axiosInstance } from "../../axiosApi";
|
import { axiosInstance } from "../../axiosApi";
|
||||||
|
import { logInRevenueCat } from "../utils/revenueCat";
|
||||||
|
|
||||||
type AccountProviderProps ={
|
type AccountProviderProps ={
|
||||||
children? : ReactNode;
|
children? : ReactNode;
|
||||||
@@ -28,6 +29,7 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
|||||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
|
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
|
||||||
|
|
||||||
const account: Account = new Account({
|
const account: Account = new Account({
|
||||||
|
id: get_user_response.data.id,
|
||||||
email: get_user_response.data.email,
|
email: get_user_response.data.email,
|
||||||
first_name: get_user_response.data.first_name,
|
first_name: get_user_response.data.first_name,
|
||||||
last_name: get_user_response.data.last_name,
|
last_name: get_user_response.data.last_name,
|
||||||
@@ -43,6 +45,9 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
setAccount(account);
|
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 }
|
export { AccountContext, AccountProvider }
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export class AdminAnalytics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountType {
|
export interface AccountType {
|
||||||
|
id?: number;
|
||||||
email: string;
|
email: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
@@ -271,6 +272,7 @@ export interface PreferencesType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Account {
|
export class Account {
|
||||||
|
id?: number;
|
||||||
email: string = '';
|
email: string = '';
|
||||||
first_name: string ='';
|
first_name: string ='';
|
||||||
last_name: string = '';
|
last_name: string = '';
|
||||||
@@ -282,6 +284,7 @@ export class Account {
|
|||||||
has_signed_tos: boolean = false;
|
has_signed_tos: boolean = false;
|
||||||
constructor(initializer?: any){
|
constructor(initializer?: any){
|
||||||
if (!initializer) return;
|
if (!initializer) return;
|
||||||
|
if (initializer.id != null) this.id = Number(initializer.id);
|
||||||
if (initializer.email) this.email = initializer.email;
|
if (initializer.email) this.email = initializer.email;
|
||||||
if (initializer.first_name) this.first_name = initializer.first_name;
|
if (initializer.first_name) this.first_name = initializer.first_name;
|
||||||
if (initializer.is_company_manager) this.is_company_manager = initializer.is_company_manager;
|
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('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||||
|
|
||||||
|
jest.mock('../../utils/revenueCat', () => ({
|
||||||
|
logInRevenueCat: () => Promise.resolve(),
|
||||||
|
purchasePlan: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockPost = jest.fn();
|
const mockPost = jest.fn();
|
||||||
const mockGet = jest.fn();
|
const mockGet = jest.fn();
|
||||||
const mockApplyAccessToken = jest.fn();
|
const mockApplyAccessToken = jest.fn();
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { AccountContext } from '../../contexts/AccountContext';
|
|||||||
import { Account, AccountType } from '../../data';
|
import { Account, AccountType } from '../../data';
|
||||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||||
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
|
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`
|
const PageContainer = styled.div`
|
||||||
position: relative;
|
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 AuthCallback = (): JSX.Element => {
|
||||||
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||||
const { setAccount } = useContext(AccountContext);
|
const { setAccount } = useContext(AccountContext);
|
||||||
@@ -142,6 +137,7 @@ const AuthCallback = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const account = new Account({
|
const account = new Account({
|
||||||
|
id: get_user_response.data.id,
|
||||||
email: get_user_response.data.email,
|
email: get_user_response.data.email,
|
||||||
first_name: get_user_response.data.first_name,
|
first_name: get_user_response.data.first_name,
|
||||||
last_name: get_user_response.data.last_name,
|
last_name: get_user_response.data.last_name,
|
||||||
@@ -165,8 +161,23 @@ const AuthCallback = (): JSX.Element => {
|
|||||||
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' });
|
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' });
|
||||||
}
|
}
|
||||||
identifyAccount(account);
|
identifyAccount(account);
|
||||||
|
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
||||||
|
console.warn('RevenueCat logIn', err)
|
||||||
|
);
|
||||||
|
|
||||||
if (needsCheckout) {
|
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…');
|
setStatusText('Starting checkout…');
|
||||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
|
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { AccountContext } from '../../contexts/AccountContext';
|
|||||||
|
|
||||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||||
|
|
||||||
|
jest.mock('../../utils/revenueCat', () => ({
|
||||||
|
logInRevenueCat: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockPost = jest.fn();
|
const mockPost = jest.fn();
|
||||||
const mockGet = jest.fn();
|
const mockGet = jest.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Account, AccountType } from '../../data';
|
|||||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||||
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
||||||
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
|
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
|
||||||
|
import { logInRevenueCat } from '../../utils/revenueCat';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
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 get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
|
||||||
|
|
||||||
const account = new Account({
|
const account = new Account({
|
||||||
|
id: get_user_response.data.id,
|
||||||
email: get_user_response.data.email,
|
email: get_user_response.data.email,
|
||||||
first_name: get_user_response.data.first_name,
|
first_name: get_user_response.data.first_name,
|
||||||
last_name: get_user_response.data.last_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)
|
setNeedsNewPassword(get_user_response.data.has_usable_password)
|
||||||
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' });
|
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' });
|
||||||
identifyAccount(account);
|
identifyAccount(account);
|
||||||
|
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
||||||
|
console.warn('RevenueCat logIn', err)
|
||||||
|
);
|
||||||
if (account.has_signed_tos) {
|
if (account.has_signed_tos) {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { AccountContext } from '../../contexts/AccountContext';
|
|||||||
|
|
||||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||||
|
|
||||||
|
jest.mock('../../utils/revenueCat', () => ({
|
||||||
|
logInRevenueCat: () => Promise.resolve(),
|
||||||
|
purchasePlan: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockPost = jest.fn();
|
const mockPost = jest.fn();
|
||||||
const mockGet = jest.fn();
|
const mockGet = jest.fn();
|
||||||
const assignMock = jest.fn();
|
const assignMock = jest.fn();
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { Account, AccountType } from '../../data';
|
|||||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||||
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
||||||
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
|
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 styled from 'styled-components';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
||||||
@@ -170,14 +173,6 @@ const validationSchema = Yup.object().shape({
|
|||||||
company_name: Yup.string(),
|
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 SignUp = (): JSX.Element => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||||
@@ -213,6 +208,7 @@ const SignUp = (): JSX.Element => {
|
|||||||
const loadAccount = async (): Promise<Account> => {
|
const loadAccount = async (): Promise<Account> => {
|
||||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/');
|
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/');
|
||||||
const account = new Account({
|
const account = new Account({
|
||||||
|
id: get_user_response.data.id,
|
||||||
email: get_user_response.data.email,
|
email: get_user_response.data.email,
|
||||||
first_name: get_user_response.data.first_name,
|
first_name: get_user_response.data.first_name,
|
||||||
last_name: get_user_response.data.last_name,
|
last_name: get_user_response.data.last_name,
|
||||||
@@ -249,14 +245,31 @@ const SignUp = (): JSX.Element => {
|
|||||||
const account = await loadAccount();
|
const account = await loadAccount();
|
||||||
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
|
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
|
||||||
identifyAccount(account);
|
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;
|
const needsCheckout = registerResponse.data?.needs_checkout !== false;
|
||||||
if (!needsCheckout) {
|
if (!needsCheckout) {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
return;
|
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' });
|
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
|
||||||
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||||
success_url,
|
success_url,
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
formatMoneyCents,
|
formatMoneyCents,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
|
invoiceProviderLabel,
|
||||||
|
isComplimentarySubscription,
|
||||||
|
isStoreSubscription,
|
||||||
|
isStripeSubscription,
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
planAllowsRag,
|
planAllowsRag,
|
||||||
} from './finance';
|
} from './finance';
|
||||||
@@ -92,9 +96,44 @@ describe('finance helpers', () => {
|
|||||||
expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
|
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: 'open' })])).toBe(false);
|
||||||
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type FinanceInvoice = {
|
|||||||
stripe_invoice_id: string | null;
|
stripe_invoice_id: string | null;
|
||||||
stripe_checkout_session_id: string | null;
|
stripe_checkout_session_id: string | null;
|
||||||
stripe_subscription_id: string | null;
|
stripe_subscription_id: string | null;
|
||||||
|
revenuecat_event_id?: string | null;
|
||||||
|
revenuecat_store?: string | null;
|
||||||
hosted_invoice_url: string;
|
hosted_invoice_url: string;
|
||||||
description: string;
|
description: string;
|
||||||
created: string;
|
created: string;
|
||||||
@@ -25,6 +27,7 @@ export type FinancePayment = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
stripe_payment_intent_id: string | null;
|
stripe_payment_intent_id: string | null;
|
||||||
stripe_charge_id: string | null;
|
stripe_charge_id: string | null;
|
||||||
|
revenuecat_transaction_id?: string | null;
|
||||||
paid_at: string | null;
|
paid_at: string | null;
|
||||||
failure_message: string;
|
failure_message: string;
|
||||||
created: string;
|
created: string;
|
||||||
@@ -75,12 +78,61 @@ export type SubscriptionMe = {
|
|||||||
source: string;
|
source: string;
|
||||||
needs_checkout: boolean;
|
needs_checkout: boolean;
|
||||||
stripe_subscription_id: string;
|
stripe_subscription_id: string;
|
||||||
|
revenuecat_original_transaction_id?: string;
|
||||||
cancel_at_period_end?: boolean;
|
cancel_at_period_end?: boolean;
|
||||||
current_period_end?: string | null;
|
current_period_end?: string | null;
|
||||||
usage: SubscriptionUsage;
|
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. */
|
/** Complimentary / admin-granted access — no Stripe cancel/change. */
|
||||||
export function isComplimentarySubscription(
|
export function isComplimentarySubscription(
|
||||||
@@ -89,6 +141,13 @@ export function isComplimentarySubscription(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!subscription) return false;
|
if (!subscription) return false;
|
||||||
if (subscription.source === 'backer' || subscription.source === 'admin') return true;
|
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;
|
return !hasPortalAccess && subscription.needs_checkout === false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +230,12 @@ export function pickPrimaryInvoice(invoices: FinanceInvoice[]): FinanceInvoice |
|
|||||||
return invoices[0];
|
return invoices[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stripe customer portal only — ignore RevenueCat / store invoices. */
|
||||||
export function canOpenBillingPortal(invoices: FinanceInvoice[]): boolean {
|
export function canOpenBillingPortal(invoices: FinanceInvoice[]): boolean {
|
||||||
return invoices.some(
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
Vendored
+1
@@ -9,6 +9,7 @@ interface CapacitorPreferencesPlugin {
|
|||||||
interface CapacitorBridge {
|
interface CapacitorBridge {
|
||||||
isNativePlatform?: () => boolean;
|
isNativePlatform?: () => boolean;
|
||||||
isNative?: boolean;
|
isNative?: boolean;
|
||||||
|
getPlatform?: () => string;
|
||||||
Plugins?: {
|
Plugins?: {
|
||||||
Preferences?: CapacitorPreferencesPlugin;
|
Preferences?: CapacitorPreferencesPlugin;
|
||||||
App?: { addListener?: (...args: unknown[]) => unknown };
|
App?: { addListener?: (...args: unknown[]) => unknown };
|
||||||
|
|||||||
Reference in New Issue
Block a user