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
+701 -225

No files matched your search

+4 -10
View File
@@ -40,19 +40,13 @@ npm run android:sync
Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`.
### RevenueCat IAP (#100)
### Play Billing (#78)
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
Native billing uses the local Capacitor plugin `native-store-billing` (Play Billing on Android, StoreKit on iOS). Web keeps Stripe Checkout / Customer Portal.
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
The Play product id is the plan's `store_product_id`. The backend verifies the purchase token with the Play Developer API (`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
| 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).
After plugin changes: `npm run build:mobile` (runs `cap sync`).
## Versioning
+8 -25
View File
@@ -1,40 +1,23 @@
# Monetization (web Stripe + native RevenueCat)
# Monetization (web Stripe + native store billing)
## Channels
| Runtime | Checkout / manage | Ledger |
|---------|-------------------|--------|
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
| Native Capacitor | StoreKit (iOS) and Play Billing (Android) | `POST /monetization/store/confirm/` verifies the store transaction and writes the same invoice/payment tables |
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
There is no App Store, Play, or RevenueCat webhook. After the store sheet succeeds, the app sends the signed StoreKit transaction or the Play purchase token. The backend checks it before granting access. Renewals and restores send the current store entitlement again. Access ends when the verified period end passes.
## App user id
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` (`app_store`, `play_store`, or legacy `revenuecat`) plus `revenuecat_store`.
RevenueCat `appUserID` = Django user pk as string.
## Store account token
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
`GET /monetization/subscription/` returns `store_account_token`. The native purchase passes that value as StoreKit `appAccountToken` and Play Billing `obfuscatedAccountId`. The backend rejects a token that belongs to a different user, and rejects a transaction already linked to another account.
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.
Plan rows expose `store_product_id` (the store product identifier). Configure it on `SubscriptionPlan.revenuecat_product_id`, or set `REVENUECAT_PRODUCT_PLAN_MAP` so a product id maps to a plan slug.
## Related
- Issue #100 (FE) + companion backend RevenueCat webhook PR
- Backend issue #78
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
@@ -13,6 +13,7 @@ dependencies {
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
implementation project(':native-store-billing')
}
+3
View File
@@ -13,3 +13,6 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/@capaci
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':native-store-billing'
project(':native-store-billing').projectDir = new File('../plugins/native-store-billing/android')
+4 -4
View File
@@ -283,7 +283,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -334,7 +334,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -350,7 +350,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
@@ -370,7 +370,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
+2 -1
View File
@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
platform :ios, '15.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -15,6 +15,7 @@ def capacitor_pods
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'NativeStoreBilling', :path => '../../plugins/native-store-billing'
end
target 'App' do
+11 -19
View File
@@ -21,7 +21,6 @@
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
@@ -39,6 +38,7 @@
"lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1",
"native-store-billing": "file:plugins/native-store-billing",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6",
@@ -5239,24 +5239,6 @@
"react": ">=16.14.0"
}
},
"node_modules/@revenuecat/purchases-capacitor": {
"version": "11.3.2",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-capacitor/-/purchases-capacitor-11.3.2.tgz",
"integrity": "sha512-3T4/lcAwpbPagrT4DuXvJ+8RewzFmHf3fQJQuhVy+uTQk5HGMHNlXcm2A/UdyHtWqU+/VqWbCClPFYGnpnNXAQ==",
"license": "MIT",
"dependencies": {
"@revenuecat/purchases-typescript-internal-esm": "17.25.0"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@revenuecat/purchases-typescript-internal-esm": {
"version": "17.25.0",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal-esm/-/purchases-typescript-internal-esm-17.25.0.tgz",
"integrity": "sha512-KC4BjFaQclXqFafG1Enh7t8GSwdg8p905by7UrHq0WSJmLhOR6yXSj6WDv1iwsFSAZxPPwgozNxt+U6IOFTdCA==",
"license": "MIT"
},
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -16639,6 +16621,10 @@
"node": ">= 10.x"
}
},
"node_modules/native-store-billing": {
"resolved": "plugins/native-store-billing",
"link": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -23984,6 +23970,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"plugins/native-store-billing": {
"version": "0.1.0",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"vendor/brace-expansion-compat": {
"name": "brace-expansion",
"version": "5.0.8",
+1 -1
View File
@@ -11,12 +11,12 @@
"@capacitor/keyboard": "^7.0.6",
"@capacitor/preferences": "^7.0.4",
"@capacitor/status-bar": "^7.0.6",
"native-store-billing": "file:plugins/native-store-billing",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
@@ -0,0 +1,17 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
s.name = 'NativeStoreBilling'
s.version = package['version']
s.summary = package['description']
s.license = 'MIT'
s.homepage = 'https://aimloperations.com'
s.author = 'AI ML Operations'
s.source = { :path => '.' }
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end
@@ -0,0 +1,30 @@
apply plugin: 'com.android.library'
android {
namespace "ai.hesychia.chat.billing"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
lintOptions {
abortOnError false
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "com.android.billingclient:billing:7.1.1"
}
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,243 @@
package ai.hesychia.chat.billing;
import android.app.Activity;
import com.android.billingclient.api.AcknowledgePurchaseParams;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.PendingPurchasesParams;
import com.android.billingclient.api.ProductDetails;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.json.JSONObject;
@CapacitorPlugin(name = "NativeStoreBilling")
public class NativeStoreBillingPlugin extends Plugin implements PurchasesUpdatedListener {
private BillingClient billingClient;
private PluginCall pendingPurchase;
private String pendingProductId = "";
@PluginMethod
public void purchase(PluginCall call) {
String productId = call.getString("productId", "");
String accountToken = call.getString("accountToken", "");
if (productId == null || productId.isEmpty()) {
call.reject("productId is required");
return;
}
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() ->
connect(call, () -> queryAndLaunch(call, productId, accountToken == null ? "" : accountToken))
);
}
@PluginMethod
public void restore(PluginCall call) {
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() -> connect(call, () -> queryOwned(call, "")));
}
private void connect(PluginCall call, Runnable whenReady) {
if (billingClient != null && billingClient.isReady()) {
whenReady.run();
return;
}
billingClient = BillingClient.newBuilder(getContext())
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build();
billingClient.startConnection(
new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(BillingResult billingResult) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
whenReady.run();
return;
}
call.reject("Play Billing setup failed: " + billingResult.getDebugMessage());
}
@Override
public void onBillingServiceDisconnected() {
// The next purchase reconnects.
}
}
);
}
private void queryAndLaunch(PluginCall call, String productId, String accountToken) {
QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder()
.setProductList(
Collections.singletonList(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
)
.build();
billingClient.queryProductDetailsAsync(params, (billingResult, productDetailsList) -> {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
call.reject("Could not load Play product: " + billingResult.getDebugMessage());
return;
}
if (productDetailsList == null || productDetailsList.isEmpty()) {
call.reject("Play product was not found: " + productId);
return;
}
ProductDetails details = productDetailsList.get(0);
List<ProductDetails.SubscriptionOfferDetails> offers = details.getSubscriptionOfferDetails();
if (offers == null || offers.isEmpty()) {
call.reject("Play product has no subscription offer");
return;
}
String offerToken = offers.get(0).getOfferToken();
BillingFlowParams.ProductDetailsParams productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken)
.build();
BillingFlowParams.Builder flow = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(Collections.singletonList(productParams));
if (!accountToken.isEmpty()) {
flow.setObfuscatedAccountId(accountToken);
}
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() -> {
pendingPurchase = call;
pendingProductId = productId;
BillingResult launched = billingClient.launchBillingFlow(activity, flow.build());
if (launched.getResponseCode() != BillingClient.BillingResponseCode.OK) {
pendingPurchase = null;
call.reject("Could not open Play purchase sheet: " + launched.getDebugMessage());
}
});
});
}
@Override
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
PluginCall call = pendingPurchase;
pendingPurchase = null;
if (call == null) {
return;
}
int code = billingResult.getResponseCode();
if (code == BillingClient.BillingResponseCode.USER_CANCELED) {
JSObject data = new JSObject();
data.put("userCancelled", true);
call.reject("Purchase cancelled", "PURCHASE_CANCELLED", null, data);
return;
}
if (code == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
queryOwned(call, pendingProductId);
return;
}
if (code != BillingClient.BillingResponseCode.OK || purchases == null || purchases.isEmpty()) {
call.reject("Play purchase failed: " + billingResult.getDebugMessage());
return;
}
Purchase match = null;
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
continue;
}
if (pendingProductId.isEmpty() || purchase.getProducts().contains(pendingProductId)) {
match = purchase;
break;
}
}
if (match == null) {
call.reject("Play purchase did not complete");
return;
}
acknowledge(match);
call.resolve(purchaseToJson(match));
}
private void queryOwned(PluginCall call, String productId) {
QueryPurchasesParams params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build();
billingClient.queryPurchasesAsync(params, (billingResult, purchases) -> {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
call.reject("Could not restore Play purchases: " + billingResult.getDebugMessage());
return;
}
JSArray rows = new JSArray();
Purchase single = null;
List<Purchase> owned = purchases == null ? new ArrayList<>() : purchases;
for (Purchase purchase : owned) {
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
continue;
}
if (!productId.isEmpty() && !purchase.getProducts().contains(productId)) {
continue;
}
acknowledge(purchase);
JSONObject row = purchaseToJson(purchase);
rows.put(row);
single = purchase;
}
if (!productId.isEmpty()) {
if (single == null) {
call.reject("No active Play purchase for " + productId);
return;
}
call.resolve(purchaseToJson(single));
return;
}
JSObject result = new JSObject();
result.put("transactions", rows);
call.resolve(result);
});
}
private void acknowledge(Purchase purchase) {
if (purchase.isAcknowledged() || billingClient == null) {
return;
}
AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
billingClient.acknowledgePurchase(params, billingResult -> { });
}
private JSObject purchaseToJson(Purchase purchase) {
String productId = purchase.getProducts().isEmpty() ? "" : purchase.getProducts().get(0);
String orderId = purchase.getOrderId() == null ? purchase.getPurchaseToken() : purchase.getOrderId();
JSObject row = new JSObject();
row.put("store", "play_store");
row.put("productId", productId);
row.put("transactionId", orderId);
row.put("purchaseToken", purchase.getPurchaseToken());
row.put("signedTransaction", "");
return row;
}
}
+1
View File
@@ -0,0 +1 @@
export {};
+2
View File
@@ -0,0 +1,2 @@
// Native code is loaded by Capacitor. The web app registers the plugin from src.
module.exports = {};
@@ -0,0 +1,94 @@
import Foundation
import Capacitor
import StoreKit
@objc(NativeStoreBillingPlugin)
public class NativeStoreBillingPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "NativeStoreBillingPlugin"
public let jsName = "NativeStoreBilling"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "restore", returnType: CAPPluginReturnPromise)
]
@objc func purchase(_ call: CAPPluginCall) {
let productId = call.getString("productId") ?? ""
let accountToken = call.getString("accountToken") ?? ""
if productId.isEmpty {
call.reject("productId is required")
return
}
Task {
do {
let products = try await Product.products(for: [productId])
guard let product = products.first else {
call.reject("App Store product was not found: \(productId)")
return
}
var options = Set<Product.PurchaseOption>()
if let uuid = UUID(uuidString: accountToken) {
options.insert(.appAccountToken(uuid))
}
let result = try await product.purchase(options: options)
switch result {
case .success(let verification):
let transaction = try Self.checkVerified(verification)
let payload = Self.payload(transaction: transaction, jws: verification.jwsRepresentation)
await transaction.finish()
call.resolve(payload)
case .userCancelled:
call.reject("Purchase cancelled", "PURCHASE_CANCELLED", nil, ["userCancelled": true])
case .pending:
call.reject("App Store purchase is still pending")
@unknown default:
call.reject("Unknown App Store purchase result")
}
} catch {
call.reject(error.localizedDescription)
}
}
}
@objc func restore(_ call: CAPPluginCall) {
Task {
var rows: [JSObject] = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
if transaction.revocationDate != nil {
continue
}
rows.append(Self.payload(transaction: transaction, jws: result.jwsRepresentation))
}
call.resolve(["transactions": rows])
}
}
private static func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw NSError(
domain: "NativeStoreBilling",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "App Store could not verify this transaction"]
)
case .verified(let safe):
return safe
}
}
private static func payload(transaction: Transaction, jws: String) -> JSObject {
var row: JSObject = [
"store": "app_store",
"productId": transaction.productID,
"transactionId": String(transaction.id),
"purchaseToken": "",
"signedTransaction": jws
]
if let expires = transaction.expirationDate {
row["expiresAt"] = ISO8601DateFormatter().string(from: expires)
}
return row
}
}
@@ -0,0 +1,26 @@
{
"name": "native-store-billing",
"version": "0.1.0",
"private": true,
"description": "StoreKit and Play Billing purchases for the Hesychia Capacitor shell.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
},
"files": [
"android/src/main/",
"android/build.gradle",
"ios/Sources",
"NativeStoreBilling.podspec",
"dist/"
],
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
}
@@ -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,
+22 -139
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 });
}
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: [] })),
},
}));