Buy on StoreKit and Play Billing, then confirm with the backend (#78).
Unit Tests / test (pull_request) Successful in 13s
Unit Tests / test (pull_request) Successful in 13s
Native purchases replace RevenueCat. The app sends the signed transaction or Play token to /monetization/store/confirm/ so access is granted only after the store check.
This commit is contained in:
1 parent
4a5b05f421
commit
c938995380
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'
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: [] })),
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user