From 8b581920265cfb816a9cfb6f9c5387ff40b9be13 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 19 Oct 2026 04:38:43 -0700 Subject: [PATCH] Buy on StoreKit and Play Billing, then confirm with the backend (#78) (#122) ## 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: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/122 --- llm-fe/ANDROID.md | 14 +- llm-fe/MONETIZATION.md | 33 +-- llm-fe/android/app/capacitor.build.gradle | 1 + llm-fe/android/capacitor.settings.gradle | 3 + llm-fe/ios/App/App.xcodeproj/project.pbxproj | 8 +- llm-fe/ios/App/Podfile | 3 +- llm-fe/package-lock.json | 30 +-- llm-fe/package.json | 2 +- .../NativeStoreBilling.podspec | 17 ++ .../native-store-billing/android/build.gradle | 30 +++ .../android/src/main/AndroidManifest.xml | 2 + .../billing/NativeStoreBillingPlugin.java | 243 ++++++++++++++++++ .../native-store-billing/dist/index.d.ts | 1 + .../native-store-billing/dist/index.js | 2 + .../NativeStoreBillingPlugin.swift | 94 +++++++ .../plugins/native-store-billing/package.json | 26 ++ .../BillingSection/BillingSection.tsx | 8 +- .../src/llm-fe/plugins/nativeStoreBilling.ts | 25 ++ llm-fe/src/llm-fe/utils/finance.test.ts | 5 + llm-fe/src/llm-fe/utils/finance.ts | 11 +- llm-fe/src/llm-fe/utils/revenueCat.test.ts | 11 - llm-fe/src/llm-fe/utils/revenueCat.ts | 163 ++---------- llm-fe/src/llm-fe/utils/storeBilling.test.ts | 99 +++++++ llm-fe/src/llm-fe/utils/storeBilling.ts | 81 ++++++ llm-fe/src/setupTests.ts | 16 +- 25 files changed, 702 insertions(+), 226 deletions(-) create mode 100644 llm-fe/plugins/native-store-billing/NativeStoreBilling.podspec create mode 100644 llm-fe/plugins/native-store-billing/android/build.gradle create mode 100644 llm-fe/plugins/native-store-billing/android/src/main/AndroidManifest.xml create mode 100644 llm-fe/plugins/native-store-billing/android/src/main/java/ai/hesychia/chat/billing/NativeStoreBillingPlugin.java create mode 100644 llm-fe/plugins/native-store-billing/dist/index.d.ts create mode 100644 llm-fe/plugins/native-store-billing/dist/index.js create mode 100644 llm-fe/plugins/native-store-billing/ios/Sources/NativeStoreBillingPlugin/NativeStoreBillingPlugin.swift create mode 100644 llm-fe/plugins/native-store-billing/package.json create mode 100644 llm-fe/src/llm-fe/plugins/nativeStoreBilling.ts create mode 100644 llm-fe/src/llm-fe/utils/storeBilling.test.ts create mode 100644 llm-fe/src/llm-fe/utils/storeBilling.ts diff --git a/llm-fe/ANDROID.md b/llm-fe/ANDROID.md index e409188..8b20ea2 100644 --- a/llm-fe/ANDROID.md +++ b/llm-fe/ANDROID.md @@ -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 diff --git a/llm-fe/MONETIZATION.md b/llm-fe/MONETIZATION.md index 3ab2589..16fb153 100644 --- a/llm-fe/MONETIZATION.md +++ b/llm-fe/MONETIZATION.md @@ -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 diff --git a/llm-fe/android/app/capacitor.build.gradle b/llm-fe/android/app/capacitor.build.gradle index 3d24cd4..f62481d 100644 --- a/llm-fe/android/app/capacitor.build.gradle +++ b/llm-fe/android/app/capacitor.build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(':capacitor-keyboard') implementation project(':capacitor-preferences') implementation project(':capacitor-status-bar') + implementation project(':native-store-billing') } diff --git a/llm-fe/android/capacitor.settings.gradle b/llm-fe/android/capacitor.settings.gradle index 56ae62c..0b2c0e0 100644 --- a/llm-fe/android/capacitor.settings.gradle +++ b/llm-fe/android/capacitor.settings.gradle @@ -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') diff --git a/llm-fe/ios/App/App.xcodeproj/project.pbxproj b/llm-fe/ios/App/App.xcodeproj/project.pbxproj index 4ab4b50..f9de089 100644 --- a/llm-fe/ios/App/App.xcodeproj/project.pbxproj +++ b/llm-fe/ios/App/App.xcodeproj/project.pbxproj @@ -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; diff --git a/llm-fe/ios/App/Podfile b/llm-fe/ios/App/Podfile index 311631e..4257a06 100644 --- a/llm-fe/ios/App/Podfile +++ b/llm-fe/ios/App/Podfile @@ -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 diff --git a/llm-fe/package-lock.json b/llm-fe/package-lock.json index fa6153f..f79d753 100644 --- a/llm-fe/package-lock.json +++ b/llm-fe/package-lock.json @@ -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", diff --git a/llm-fe/package.json b/llm-fe/package.json index 6d7f882..97b159d 100644 --- a/llm-fe/package.json +++ b/llm-fe/package.json @@ -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", diff --git a/llm-fe/plugins/native-store-billing/NativeStoreBilling.podspec b/llm-fe/plugins/native-store-billing/NativeStoreBilling.podspec new file mode 100644 index 0000000..aa09066 --- /dev/null +++ b/llm-fe/plugins/native-store-billing/NativeStoreBilling.podspec @@ -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 diff --git a/llm-fe/plugins/native-store-billing/android/build.gradle b/llm-fe/plugins/native-store-billing/android/build.gradle new file mode 100644 index 0000000..073377d --- /dev/null +++ b/llm-fe/plugins/native-store-billing/android/build.gradle @@ -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" +} diff --git a/llm-fe/plugins/native-store-billing/android/src/main/AndroidManifest.xml b/llm-fe/plugins/native-store-billing/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a2f47b6 --- /dev/null +++ b/llm-fe/plugins/native-store-billing/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/llm-fe/plugins/native-store-billing/android/src/main/java/ai/hesychia/chat/billing/NativeStoreBillingPlugin.java b/llm-fe/plugins/native-store-billing/android/src/main/java/ai/hesychia/chat/billing/NativeStoreBillingPlugin.java new file mode 100644 index 0000000..fa1ba9e --- /dev/null +++ b/llm-fe/plugins/native-store-billing/android/src/main/java/ai/hesychia/chat/billing/NativeStoreBillingPlugin.java @@ -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 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 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 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; + } +} diff --git a/llm-fe/plugins/native-store-billing/dist/index.d.ts b/llm-fe/plugins/native-store-billing/dist/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/llm-fe/plugins/native-store-billing/dist/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/llm-fe/plugins/native-store-billing/dist/index.js b/llm-fe/plugins/native-store-billing/dist/index.js new file mode 100644 index 0000000..6678251 --- /dev/null +++ b/llm-fe/plugins/native-store-billing/dist/index.js @@ -0,0 +1,2 @@ +// Native code is loaded by Capacitor. The web app registers the plugin from src. +module.exports = {}; diff --git a/llm-fe/plugins/native-store-billing/ios/Sources/NativeStoreBillingPlugin/NativeStoreBillingPlugin.swift b/llm-fe/plugins/native-store-billing/ios/Sources/NativeStoreBillingPlugin/NativeStoreBillingPlugin.swift new file mode 100644 index 0000000..15f7ee2 --- /dev/null +++ b/llm-fe/plugins/native-store-billing/ios/Sources/NativeStoreBillingPlugin/NativeStoreBillingPlugin.swift @@ -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() + 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(_ result: VerificationResult) 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 + } +} diff --git a/llm-fe/plugins/native-store-billing/package.json b/llm-fe/plugins/native-store-billing/package.json new file mode 100644 index 0000000..c25dc9e --- /dev/null +++ b/llm-fe/plugins/native-store-billing/package.json @@ -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" + } +} diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx index 04eff29..b270ebb 100644 --- a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx @@ -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)) { diff --git a/llm-fe/src/llm-fe/plugins/nativeStoreBilling.ts b/llm-fe/src/llm-fe/plugins/nativeStoreBilling.ts new file mode 100644 index 0000000..af97b3d --- /dev/null +++ b/llm-fe/src/llm-fe/plugins/nativeStoreBilling.ts @@ -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; + restore(options: { + accountToken: string; + }): Promise<{ transactions: NativeStoreTransaction[] }>; +} + +export const NativeStoreBilling = registerPlugin( + 'NativeStoreBilling' +); diff --git a/llm-fe/src/llm-fe/utils/finance.test.ts b/llm-fe/src/llm-fe/utils/finance.test.ts index b045969..0d69850 100644 --- a/llm-fe/src/llm-fe/utils/finance.test.ts +++ b/llm-fe/src/llm-fe/utils/finance.test.ts @@ -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( diff --git a/llm-fe/src/llm-fe/utils/finance.ts b/llm-fe/src/llm-fe/utils/finance.ts index 33d407b..7ee68d7 100644 --- a/llm-fe/src/llm-fe/utils/finance.ts +++ b/llm-fe/src/llm-fe/utils/finance.ts @@ -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 ): 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 ( diff --git a/llm-fe/src/llm-fe/utils/revenueCat.test.ts b/llm-fe/src/llm-fe/utils/revenueCat.test.ts index c38065b..ac6a0bb 100644 --- a/llm-fe/src/llm-fe/utils/revenueCat.test.ts +++ b/llm-fe/src/llm-fe/utils/revenueCat.test.ts @@ -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, diff --git a/llm-fe/src/llm-fe/utils/revenueCat.ts b/llm-fe/src/llm-fe/utils/revenueCat.ts index 86469ee..1026f7d 100644 --- a/llm-fe/src/llm-fe/utils/revenueCat.ts +++ b/llm-fe/src/llm-fe/utils/revenueCat.ts @@ -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 | 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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); } diff --git a/llm-fe/src/llm-fe/utils/storeBilling.test.ts b/llm-fe/src/llm-fe/utils/storeBilling.test.ts new file mode 100644 index 0000000..6698abd --- /dev/null +++ b/llm-fe/src/llm-fe/utils/storeBilling.test.ts @@ -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(); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/storeBilling.ts b/llm-fe/src/llm-fe/utils/storeBilling.ts new file mode 100644 index 0000000..56de34c --- /dev/null +++ b/llm-fe/src/llm-fe/utils/storeBilling.ts @@ -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 { + const subRes = await axiosInstance.get( + '/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('/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 { + 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 { + 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 { + 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); + } +} diff --git a/llm-fe/src/setupTests.ts b/llm-fe/src/setupTests.ts index c67cc79..2db4d63 100644 --- a/llm-fe/src/setupTests.ts +++ b/llm-fe/src/setupTests.ts @@ -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: [] })), }, }));