Buy on StoreKit and Play Billing, then confirm with the backend (#78) #122
No files matched your search
+4
-10
@@ -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
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+11
-19
@@ -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
@@ -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>
|
||||
+243
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,2 @@
|
||||
// Native code is loaded by Capacitor. The web app registers the plugin from src.
|
||||
module.exports = {};
|
||||
+94
@@ -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'
|
||||
);
|
||||
@@ -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