Buy on StoreKit and Play Billing, then confirm with the backend (#78) (#122)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m9s

## Summary
- The Capacitor shell buys through StoreKit (iOS 15) and Play Billing instead of RevenueCat.
- After the store sheet succeeds, the app POSTs the signed transaction or Play purchase token to `/monetization/store/confirm/`.
- Restore reads current store entitlements and confirms each one. Web checkout stays on Stripe.
- The store account token from `GET /monetization/subscription/` is sent as `appAccountToken` / `obfuscatedAccountId`.

Depends on the `chat_backend` PR for issue #78 (`feat/78-native-store-purchases`).

## Test plan
- [x] `storeBilling`, `revenueCat`, `finance`, and `BillingSection` unit tests
- [ ] iOS sandbox purchase and restore against a backend with `APPLE_IAP_BUNDLE_ID` set
- [ ] Play license-test purchase and restore against a backend with `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` set
- [ ] Web billing still opens Stripe Checkout

Reviewed-on: #122
This commit was merged in pull request #122.
This commit is contained in:
westfarn committed 2026-10-19 04:38:43 -07:00
1 parent 4a5b05f421
commit 8b58192026
25 files changed
+702 -226

No files matched your search

@@ -367,7 +367,9 @@ const BillingSection = (): JSX.Element => {
date: payment.paid_at || payment.created,
description: 'Payment',
provider:
(payment.provider || '').toLowerCase() === 'revenuecat'
['revenuecat', 'app_store', 'play_store'].includes(
(payment.provider || '').toLowerCase()
)
? 'Store'
: (payment.provider || '').toLowerCase() === 'stripe'
? 'Stripe'
@@ -414,9 +416,7 @@ const BillingSection = (): JSX.Element => {
...(planSlug ? { plan_slug: planSlug } : {}),
});
await purchasePlan(planSlug);
setActionNotice(
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
);
setActionNotice('Purchase complete.');
await loadBilling();
} catch (error: unknown) {
if (isPurchaseCancelledError(error)) {
@@ -0,0 +1,25 @@
import { registerPlugin } from '@capacitor/core';
export type StoreName = 'app_store' | 'play_store';
export interface NativeStoreTransaction {
store: StoreName;
productId: string;
transactionId: string;
purchaseToken?: string;
signedTransaction?: string;
}
export interface NativeStoreBillingPlugin {
purchase(options: {
productId: string;
accountToken: string;
}): Promise<NativeStoreTransaction>;
restore(options: {
accountToken: string;
}): Promise<{ transactions: NativeStoreTransaction[] }>;
}
export const NativeStoreBilling = registerPlugin<NativeStoreBillingPlugin>(
'NativeStoreBilling'
);
+5
View File
@@ -127,6 +127,11 @@ describe('finance helpers', () => {
it('classifies subscription sources', () => {
expect(isStoreSubscription('revenuecat')).toBe(true);
expect(isStoreSubscription('app_store')).toBe(true);
expect(isStoreSubscription('play_store')).toBe(true);
expect(invoiceProviderLabel(baseInvoice({ provider: 'app_store' }))).toBe(
'App Store'
);
expect(isStripeSubscription('stripe')).toBe(true);
expect(
isComplimentarySubscription(
+10 -1
View File
@@ -55,6 +55,7 @@ export type SubscriptionPlanInfo = {
prompt_window_hours: number;
monthly_token_quota: number | null;
sort_order: number;
store_product_id?: string;
};
export type SubscriptionUsage = {
@@ -81,6 +82,7 @@ export type SubscriptionMe = {
revenuecat_original_transaction_id?: string;
cancel_at_period_end?: boolean;
current_period_end?: string | null;
store_account_token?: string;
usage: SubscriptionUsage;
};
@@ -88,6 +90,8 @@ export type SubscriptionSource =
| 'none'
| 'stripe'
| 'revenuecat'
| 'app_store'
| 'play_store'
| 'backer'
| 'admin'
| string;
@@ -95,7 +99,10 @@ export type SubscriptionSource =
export function isStoreSubscription(
source: string | null | undefined
): boolean {
return source === 'revenuecat';
const value = (source || '').toLowerCase();
return (
value === 'revenuecat' || value === 'app_store' || value === 'play_store'
);
}
export function isStripeSubscription(
@@ -109,6 +116,8 @@ export function invoiceProviderLabel(
invoice: Pick<FinanceInvoice, 'provider' | 'revenuecat_store'>
): string {
const provider = (invoice.provider || '').toLowerCase();
if (provider === 'app_store') return 'App Store';
if (provider === 'play_store') return 'Play Store';
if (provider === 'revenuecat') {
const store = (invoice.revenuecat_store || '').toUpperCase();
if (
@@ -1,14 +1,3 @@
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,
+23 -140
View File
@@ -1,44 +1,15 @@
/**
* RevenueCat Capacitor IAP wrapper (#100).
* No-ops on web; only configures / purchases on native platforms.
* Mobile purchase entry points kept under the old names so sign-in and
* billing call sites stay stable.
* Purchases run through StoreKit / Play Billing (`storeBilling`), then the
* backend verifies the store transaction. These identity hooks are no-ops:
* the store account token comes from `/monetization/subscription/`.
*/
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() !== '') {
@@ -53,124 +24,36 @@ export function resolveAppUserId(account?: AccountLike | null): string | null {
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
_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 });
return;
}
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]
);
return;
}
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 });
const { purchaseNativePlan } = await import('./storeBilling');
return purchaseNativePlan(planSlug);
}
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();
const { restoreNativePurchases } = await import('./storeBilling');
return restoreNativePurchases();
}
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';
const err = error as {
userCancelled?: boolean;
code?: number | string;
message?: string;
data?: { userCancelled?: boolean };
};
if (err?.userCancelled === true || err?.data?.userCancelled === true) return true;
if (err?.code === 1 || err?.code === '1' || err?.code === 'PURCHASE_CANCELLED') {
return true;
}
return typeof err?.message === 'string' && /purchase cancelled/i.test(err.message);
}
@@ -0,0 +1,99 @@
jest.mock('../../axiosApi', () => ({
axiosInstance: {
get: jest.fn(),
post: jest.fn(),
},
}));
jest.mock('../platform/nativePlatform', () => ({
isNativePlatform: jest.fn(() => true),
}));
jest.mock('../plugins/nativeStoreBilling', () => ({
NativeStoreBilling: {
purchase: jest.fn(),
restore: jest.fn(),
},
}));
import { axiosInstance } from '../../axiosApi';
import { isNativePlatform } from '../platform/nativePlatform';
import { NativeStoreBilling } from '../plugins/nativeStoreBilling';
import { purchaseNativePlan, restoreNativePurchases } from './storeBilling';
const mockedGet = axiosInstance.get as jest.Mock;
const mockedPost = axiosInstance.post as jest.Mock;
const mockedNative = isNativePlatform as jest.Mock;
const mockedPurchase = NativeStoreBilling.purchase as jest.Mock;
const mockedRestore = NativeStoreBilling.restore as jest.Mock;
describe('storeBilling', () => {
beforeEach(() => {
mockedGet.mockReset();
mockedPost.mockReset();
mockedPurchase.mockReset();
mockedRestore.mockReset();
mockedNative.mockReturnValue(true);
mockedPost.mockResolvedValue({ data: {} });
mockedGet.mockImplementation((url: string) => {
if (url.includes('plans')) {
return Promise.resolve({
data: [{ slug: 'founders', store_product_id: 'hesychia_founders_monthly' }],
});
}
return Promise.resolve({ data: { store_account_token: 'account-token' } });
});
});
it('confirms an App Store purchase with the signed transaction', async () => {
mockedPurchase.mockResolvedValue({
store: 'app_store',
productId: 'hesychia_founders_monthly',
transactionId: '2001',
signedTransaction: 'header.payload.sig',
});
await purchaseNativePlan('founders');
expect(mockedPurchase).toHaveBeenCalledWith({
productId: 'hesychia_founders_monthly',
accountToken: 'account-token',
});
expect(mockedPost).toHaveBeenCalledWith('/monetization/store/confirm/', {
store: 'app_store',
product_id: 'hesychia_founders_monthly',
transaction_id: '2001',
purchase_token: '',
signed_transaction: 'header.payload.sig',
});
});
it('confirms each restored Play purchase', async () => {
mockedRestore.mockResolvedValue({
transactions: [
{
store: 'play_store',
productId: 'hesychia_founders_monthly',
transactionId: 'GPA.1',
purchaseToken: 'token-1',
},
],
});
await restoreNativePurchases();
expect(mockedPost).toHaveBeenCalledWith(
'/monetization/store/confirm/',
expect.objectContaining({
store: 'play_store',
purchase_token: 'token-1',
})
);
});
it('refuses store purchase on the web', async () => {
mockedNative.mockReturnValue(false);
await expect(purchaseNativePlan()).rejects.toThrow(/mobile app/);
expect(mockedPurchase).not.toHaveBeenCalled();
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Native App Store / Play Store purchases.
* The store sheet runs on device. Entitlement is granted only after
* POST /monetization/store/confirm/ verifies the transaction.
*/
import { axiosInstance } from '../../axiosApi';
import { isNativePlatform } from '../platform/nativePlatform';
import {
NativeStoreBilling,
type NativeStoreTransaction,
} from '../plugins/nativeStoreBilling';
import type { SubscriptionPlanInfo } from './finance';
type SubscriptionTokenResponse = {
store_account_token?: string;
};
async function fetchAccountToken(): Promise<string> {
const subRes = await axiosInstance.get<SubscriptionTokenResponse>(
'/monetization/subscription/'
);
const accountToken = (subRes.data?.store_account_token || '').trim();
if (!accountToken) {
throw new Error('Missing store account token.');
}
return accountToken;
}
async function resolvePurchaseTarget(planSlug?: string): Promise<{
productId: string;
accountToken: string;
}> {
const [plansRes, accountToken] = await Promise.all([
axiosInstance.get<SubscriptionPlanInfo[]>('/monetization/plans/'),
fetchAccountToken(),
]);
const slug = (planSlug || 'founders').toLowerCase();
const plans = Array.isArray(plansRes.data) ? plansRes.data : [];
const plan = plans.find((item) => item.slug === slug);
const productId = (plan?.store_product_id || '').trim();
if (!productId) {
throw new Error(`No store product configured for plan "${slug}".`);
}
return { productId, accountToken };
}
export async function confirmStoreTransaction(
tx: NativeStoreTransaction
): Promise<void> {
await axiosInstance.post('/monetization/store/confirm/', {
store: tx.store,
product_id: tx.productId,
transaction_id: tx.transactionId || '',
purchase_token: tx.purchaseToken || '',
signed_transaction: tx.signedTransaction || '',
});
}
export async function purchaseNativePlan(planSlug?: string): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Store purchases are only available in the mobile app.');
}
const { productId, accountToken } = await resolvePurchaseTarget(planSlug);
const tx = await NativeStoreBilling.purchase({ productId, accountToken });
await confirmStoreTransaction(tx);
}
export async function restoreNativePurchases(): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Restore is only available in the mobile app.');
}
const accountToken = await fetchAccountToken();
const result = await NativeStoreBilling.restore({ accountToken });
const transactions = result?.transactions || [];
if (!transactions.length) {
throw new Error('No active store purchases found.');
}
for (const tx of transactions) {
await confirmStoreTransaction(tx);
}
}
+6 -10
View File
@@ -4,15 +4,11 @@
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Capacitor plugin ships ESM; Jest cannot parse it when AccountContext
// (or pages that import it) pull in utils/revenueCat. Stub globally.
jest.mock('@revenuecat/purchases-capacitor', () => ({
Purchases: {
configure: jest.fn(() => Promise.resolve()),
logIn: jest.fn(() => Promise.resolve({ customerInfo: {} })),
logOut: jest.fn(() => Promise.resolve({ customerInfo: {} })),
getOfferings: jest.fn(() => Promise.resolve({ current: null, all: {} })),
purchasePackage: jest.fn(() => Promise.resolve({ customerInfo: {} })),
restorePurchases: jest.fn(() => Promise.resolve({ customerInfo: {} })),
// Native store plugin is registered for Capacitor. Tests that render billing
// mock utils/revenueCat; this stub covers any direct plugin import.
jest.mock('./llm-fe/plugins/nativeStoreBilling', () => ({
NativeStoreBilling: {
purchase: jest.fn(() => Promise.resolve({})),
restore: jest.fn(() => Promise.resolve({ transactions: [] })),
},
}));