diff --git a/.github/workflows/deploy-cloud.yml b/.github/workflows/deploy-cloud.yml index a66a405..6911e55 100644 --- a/.github/workflows/deploy-cloud.yml +++ b/.github/workflows/deploy-cloud.yml @@ -22,25 +22,14 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 9 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build dashboard - run: pnpm --filter dashboard build - env: - NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }} - + # The dashboard is a STANDALONE Next.js app under dashboard/ — NOT a pnpm + # workspace (no root pnpm-lock.yaml / pnpm-workspace.yaml), so a root + # `pnpm install --frozen-lockfile` and `pnpm --filter dashboard` cannot + # work. Vercel builds the app remotely (project Root Directory = dashboard), + # so there is no local install/build step here — we only trigger the deploy. + # working-directory MUST be the repo root: the Vercel project already + # appends Root Directory=dashboard, so pointing the action at dashboard/ + # would resolve to dashboard/dashboard and fail. - name: Deploy to Vercel (production) uses: amondnet/vercel-action@v25 with: @@ -48,7 +37,7 @@ jobs: vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--prod' - working-directory: dashboard + working-directory: . deploy-functions: name: Deploy Supabase edge functions diff --git a/.gitignore b/.gitignore index 753e983..a616c86 100644 --- a/.gitignore +++ b/.gitignore @@ -109,7 +109,12 @@ cli/dist/ # Local supabase CLI artifacts (supabase/.gitignore covers .temp + .branches; # this catches the convenience symlink supabase/migrations → ../server/migrations # that local devs may add so `supabase db reset` finds the migrations). -supabase/migrations +# NOTE: migrations that must be versioned + deployed are re-included below via `!` +# negation (a blanket ignore here silently dropped schema changes on deploy — see +# migration 075 / paycraft-provider-platform-onboarding epic). Track new schema by +# adding a matching `!supabase/migrations/NNN_*.sql` line. +supabase/migrations/* +!supabase/migrations/075_routing_platform_dimension.sql .vercel # Wrangler CLI cache (Cloudflare Workers) diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..15a1a00 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,11 @@ +# Vercel deploys ONLY the dashboard (project Root Directory = dashboard). +# The surrounding KMP repo (Gradle build outputs, iOS frameworks, sample-app, +# node_modules, .next cache) must never be uploaded — several files exceed +# Vercel's 100 MB upload limit and Vercel builds the dashboard remotely anyway. +# +# Whitelist pattern: ignore every top-level entry, then re-include dashboard/, +# then drop its local install/build artifacts (installed + built remotely). +/* +!/dashboard +dashboard/node_modules +dashboard/.next diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de91d6..9cfdab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [Unreleased] — unified country detection + per-platform provider routing + +Adds a unified, cross-platform buyer-country signal and platform-aware provider selection, without touching the shipped 2.3.x storefront/native-price billing core. See `paycraft-provider-platform-onboarding` epic. + +### Added + +- **Unified country detection** — `CountryDetector` (`cmp-paycraft/.../CountryDetector.kt`) folds a new signal order `store storefront → server IP-geo → device/SIM → config locale → DEFAULT`, each tagged with a `CountryProvenance` (`AUTHORITATIVE_STORE` / `SERVER_IP_GEO` / `DEVICE_SIM` / `LOCALE_FALLBACK`). The `/config` edge function reads the hosting edge IP-country header (`x-vercel-ip-country` / `cf-ipcountry` / `cloudfront-viewer-country`) and returns `geo_country` + `geo_source`, so web/desktop (no store storefront) get an authoritative country instead of only the device locale. `CurrencyResolver.resolveCountry` gained a backward-compatible `serverGeo` param; the SDK re-resolves post-fetch and sends `X-PayCraft-Platform`. +- **Per-platform provider routing** — migration `075` adds a `platform` dimension (`ios/android/desktop/web/any`) to `tenant_routing_rules` + the upsert RPC. `checkout-router.ts` matches on platform (`platformMatches`); `/config` orders `providers[]` by the tenant's platform routing preference (`orderProvidersByPlatform`) so the SDK's `primaryProvider()` is the intended provider per platform (Stripe on desktop, Razorpay on Android, …) instead of an arbitrary `firstOrNull()`. Dashboard smart-routing editor gains a Platform column. `resolveCheckoutLane` (store-compliance) is unchanged and remains the outer guard. + +### Fixed + +- **Versioned migrations** — `supabase/migrations/` was blanket-gitignored, silently dropping every schema change from version control on deploy. `075` is now tracked via a `.gitignore` negation, with a note to track future migrations the same way. + ## [2.2.0] — Google Play Payments-policy compliance + native billing Makes PayCraft compliant with Google Play's Payments policy: on Android, digital-subscription checkout now transacts through **Google Play Billing** instead of opening an external web payment page (the anti-steering violation that flagged consumer apps such as Reels Downloader `com.sensei.social`). Web/link-out remains the path on web/desktop and for physical goods. diff --git a/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftInitializer.kt b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftInitializer.kt index 6b30b48..54e7c35 100644 --- a/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftInitializer.kt +++ b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftInitializer.kt @@ -1,5 +1,6 @@ package com.mobilebytelabs.paycraft +import android.app.Application import android.content.Context import androidx.startup.Initializer @@ -37,6 +38,11 @@ import androidx.startup.Initializer class PayCraftInitializer : Initializer { override fun create(context: Context) { PayCraftPlatform.init(context.applicationContext) + // Also start foreground-Activity tracking so native Google Play Billing + // (launchBillingFlow) works with a commonMain-only integration — the + // consumer never has to supply an activityProvider. applicationContext is + // the Application on every real app start. + (context.applicationContext as? Application)?.let(PayCraftPlatform::startActivityTracking) } override fun dependencies(): List>> = emptyList() diff --git a/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftPlatform.android.kt b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftPlatform.android.kt index fc7cc76..6513075 100644 --- a/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftPlatform.android.kt +++ b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftPlatform.android.kt @@ -1,22 +1,66 @@ package com.mobilebytelabs.paycraft +import android.app.Activity +import android.app.Application import android.content.Intent import android.net.Uri +import android.os.Bundle import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import co.touchlab.kermit.Logger import com.mobilebytelabs.paycraft.platform.DeviceTokenStore import com.russhwolf.settings.Settings import com.russhwolf.settings.SharedPreferencesSettings +import java.lang.ref.WeakReference actual object PayCraftPlatform { private var appContext: android.content.Context? = null + @Volatile + private var currentActivityRef: WeakReference? = null + fun init(context: android.content.Context) { appContext = context.applicationContext DeviceTokenStore.init(context.applicationContext) } + /** + * The captured Application context, or null if [init] never ran (startup + * Initializer disabled and no manual handoff). Used by the auto-wired + * default native billing client so a commonMain-only consumer gets real + * Google Play Billing with no androidMain wiring. + */ + internal fun applicationContextOrNull(): android.content.Context? = appContext + + /** The current foreground [Activity] (or null), tracked via [startActivityTracking]. */ + internal fun currentActivityOrNull(): Activity? = currentActivityRef?.get() + + /** + * Register foreground-Activity tracking on [app] so `launchBillingFlow` can + * resolve the resumed Activity WITHOUT the consumer supplying an + * activityProvider. Called once by [PayCraftInitializer] at app start; + * idempotent-safe (a second registration just adds a second callback that + * writes the same ref). Uses a WeakReference so a finished Activity is not + * leaked. + */ + internal fun startActivityTracking(app: Application) { + app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityResumed(activity: Activity) { + currentActivityRef = WeakReference(activity) + } + + override fun onActivityPaused(activity: Activity) { + if (currentActivityRef?.get() === activity) currentActivityRef = null + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + }) + } + /** * Creates an encrypted [Settings] instance backed by EncryptedSharedPreferences. * Use this when overriding the PayCraftStore Koin binding: diff --git a/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.android.kt b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.android.kt new file mode 100644 index 0000000..63e87fb --- /dev/null +++ b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.android.kt @@ -0,0 +1,21 @@ +package com.mobilebytelabs.paycraft.billing + +import com.mobilebytelabs.paycraft.PayCraftPlatform + +/** + * Android default = the real Google Play Billing v8 client, auto-wired from the + * Application context + foreground Activity that [PayCraftPlatform] captures via + * `PayCraftInitializer`. This is what lets an Android consumer get native Play + * Billing with ZERO androidMain wiring (no `paycraftPlayBillingModule`, no + * activityProvider). + * + * Returns `null` only when the startup Initializer was disabled AND no manual + * `PayCraftPlatform.init(...)` ran — then the caller falls back to web checkout. + */ +actual fun platformDefaultNativeBillingClient(): NativeBillingClient? { + val context = PayCraftPlatform.applicationContextOrNull() ?: return null + return PlayBillingNativeClient( + context = context, + activityProvider = { PayCraftPlatform.currentActivityOrNull() }, + ) +} diff --git a/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlayBillingNativeClient.android.kt b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlayBillingNativeClient.android.kt index 9f2e2e1..303b2cf 100644 --- a/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlayBillingNativeClient.android.kt +++ b/cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlayBillingNativeClient.android.kt @@ -9,8 +9,10 @@ import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingConfig import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.GetBillingConfigParams import com.android.billingclient.api.PendingPurchasesParams import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase @@ -149,6 +151,55 @@ class PlayBillingNativeClient(context: Context, private val activityProvider: () appContext.startActivity(intent) } + /** + * Play billing storefront country — `getBillingConfig().countryCode`. This is where the Play + * payment account lives (the true billing region), NOT the device UI locale. Lazily connects + * like [purchase] does; returns null on connect failure or when Play reports no config. + */ + override suspend fun storefrontCountry(): String? { + val connect = ensureConnected() + if (connect.responseCode != BillingResponseCode.OK) return null + // billing-ktx v8 exposes suspend wrappers for queryProductDetails / queryPurchasesAsync / + // acknowledgePurchase, but NOT for getBillingConfig — use the callback API wrapped in a + // coroutine (same pattern as ensureConnected below). + val config: BillingConfig? = suspendCancellableCoroutine { cont -> + billingClient.getBillingConfigAsync( + GetBillingConfigParams.newBuilder().build(), + ) { billingResult, billingConfig -> + if (cont.isActive) { + cont.resume( + if (billingResult.responseCode == BillingResponseCode.OK) billingConfig else null, + ) + } + } + } + return config?.countryCode?.takeIf { it.isNotBlank() } + } + + /** + * The store's own localized SUBS price — the first pricing phase of the first subscription + * offer (`formattedPrice` / `priceCurrencyCode` / `priceAmountMicros`). Null when the product + * is not on Play, has no offer, or any field is missing. + */ + override suspend fun nativeDisplayPrice(productId: String): NativeDisplayPrice? { + val connect = ensureConnected() + if (connect.responseCode != BillingResponseCode.OK) return null + val productDetails = queryProductDetails(productId) ?: return null + val phase = productDetails.subscriptionOfferDetails + ?.firstOrNull() + ?.pricingPhases + ?.pricingPhaseList + ?.firstOrNull() + ?: return null + val currency = phase.priceCurrencyCode?.takeIf { it.isNotBlank() } ?: return null + val formatted = phase.formattedPrice?.takeIf { it.isNotBlank() } ?: return null + return NativeDisplayPrice( + formatted = formatted, + currencyCode = currency, + amountMicros = phase.priceAmountMicros, + ) + } + private suspend fun queryProductDetails(productId: String): ProductDetails? { val params = QueryProductDetailsParams.newBuilder() .setProductList( diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CountryDetector.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CountryDetector.kt new file mode 100644 index 0000000..5e16dd8 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CountryDetector.kt @@ -0,0 +1,46 @@ +package com.mobilebytelabs.paycraft + +/** + * Where a resolved billing country came from, most-authoritative first. Downstream pricing/tax + * logic can decide how much to trust the value (e.g. prefer the store storefront over an IP guess, + * or prompt the user to confirm when only a weak signal is available). + * + * - [AUTHORITATIVE_STORE] — the Play/Apple payment-account storefront (Play `getBillingConfig` + * countryCode / StoreKit `Storefront.current` countryCode). The true billing region; never cached. + * - [SERVER_IP_GEO] — the country the PayCraft cloud resolved from the request's edge IP-country + * header (`geo_country` on the `/config` response). One consistent signal across every platform. + * - [DEVICE_SIM] — the device's own SIM/network/locale country ([PlatformInfo.country]). + * - [LOCALE_FALLBACK] — the cloud config locale, or [CurrencyResolver.DEFAULT_COUNTRY] as the + * absolute last resort. Weakest signal. + */ +enum class CountryProvenance { AUTHORITATIVE_STORE, SERVER_IP_GEO, DEVICE_SIM, LOCALE_FALLBACK } + +/** A resolved billing country plus the provenance of the signal it came from. */ +data class DetectedCountry(val country: String, val provenance: CountryProvenance) + +/** + * Folds a unified, cross-platform buyer-country signal from four inputs in strict priority order, + * tagging each result with its [CountryProvenance]: + * + * `store storefront → server IP-geo → device/SIM → config locale → [CurrencyResolver.DEFAULT_COUNTRY]` + * + * The store storefront is authoritative for billing (it's where the payment account lives), so it + * wins. The server IP-geo — attached by `/config` from the edge IP-country header — is one uniform + * signal that works on every platform (web/desktop included, where no store storefront exists), so + * it beats the device locale. Device/SIM and config-locale are weak fallbacks. + * + * [CurrencyResolver.resolveCountry] wraps this with the developer `override` (highest priority) and + * consumes only `.country`; the [provenance] is exposed for callers that want to gate trust. + */ +object CountryDetector { + fun resolve(storefront: String?, serverGeo: String?, deviceSim: String?, configLocale: String?): DetectedCountry { + storefront?.trim()?.takeIf { it.isNotBlank() } + ?.let { return DetectedCountry(it, CountryProvenance.AUTHORITATIVE_STORE) } + serverGeo?.trim()?.takeIf { it.isNotBlank() } + ?.let { return DetectedCountry(it, CountryProvenance.SERVER_IP_GEO) } + deviceSim?.trim()?.takeIf { it.isNotBlank() } + ?.let { return DetectedCountry(it, CountryProvenance.DEVICE_SIM) } + val fallback = configLocale?.trim()?.takeIf { it.isNotBlank() } ?: CurrencyResolver.DEFAULT_COUNTRY + return DetectedCountry(fallback, CountryProvenance.LOCALE_FALLBACK) + } +} diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt index 64068fa..709c7fc 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt @@ -21,7 +21,8 @@ data class ResolvedRegion(val country: String, val currency: String) * disagree. Centralizing the decision here guarantees price + all providers stay consistent. * * Resolution model: - * 1. [resolveCountry] picks the country ONCE (override → device → cloud locale → "US"). + * 1. [resolveCountry] picks the country ONCE (override → store storefront → device → cloud + * locale → "US"). * 2. The country is sent to `/config`, which returns per-locale prices; [resolveCurrency] * reads back the single currency the cloud resolved for that locale. * 3. [checkoutCurrency] picks each provider's checkout-link currency from that ONE active @@ -32,15 +33,39 @@ object CurrencyResolver { const val FALLBACK_CURRENCY = "USD" /** - * Decide the billing country once, override-wins: - * [override] (InitOptions.localeOverride) → [deviceCountry] (PlatformInfo.country) → - * [configLocale] (SuiteConfig.locale) → [DEFAULT_COUNTRY]. + * Decide the billing country once, override-wins, then via [CountryDetector] which folds the + * unified cross-platform signal: [override] (InitOptions.localeOverride) → [storeStorefront] + * (Play `getBillingConfig` countryCode / StoreKit `Storefront.current` countryCode) → + * [serverGeo] (the PayCraft cloud's edge IP-country, `geo_country` on `/config`) → + * [deviceCountry] (PlatformInfo.country) → [configLocale] (SuiteConfig.locale) → + * [DEFAULT_COUNTRY]. + * + * The store storefront is the region the user's Play/Apple PAYMENT ACCOUNT lives in — the true + * billing region — so it wins over everything below the developer override. The server IP-geo + * is one uniform signal available on every platform (web/desktop too, where no storefront + * exists), so it beats the device UI locale. An India buyer whose phone language is en-GB has + * an "IN" storefront and a "GB" device country; storefront-first resolves to "IN" so the paywall + * and every provider bill in ₹/INR, not £/GBP. + * + * [serverGeo] defaults to null so pre-fetch callers (before `/config` returns `geo_country`) + * resolve exactly as before; the fetch path re-resolves once the server signal is available. + * Use [CountryDetector.resolve] directly when the [CountryProvenance] of the result is needed. */ - fun resolveCountry(override: String?, deviceCountry: String?, configLocale: String?): String = - override?.trim()?.takeIf { it.isNotBlank() } - ?: deviceCountry?.trim()?.takeIf { it.isNotBlank() } - ?: configLocale?.trim()?.takeIf { it.isNotBlank() } - ?: DEFAULT_COUNTRY + fun resolveCountry( + override: String?, + storeStorefront: String?, + deviceCountry: String?, + configLocale: String?, + serverGeo: String? = null, + ): String { + override?.trim()?.takeIf { it.isNotBlank() }?.let { return it } + return CountryDetector.resolve( + storefront = storeStorefront, + serverGeo = serverGeo, + deviceSim = deviceCountry, + configLocale = configLocale, + ).country + } /** * The one currency the whole paywall uses — the currency the cloud resolved for the active diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt index 85cac4b..4ff0039 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt @@ -1,6 +1,8 @@ package com.mobilebytelabs.paycraft import com.mobilebytelabs.paycraft.billing.CheckoutLane +import com.mobilebytelabs.paycraft.billing.NativeBillingClient +import com.mobilebytelabs.paycraft.billing.NativeDisplayPrice import com.mobilebytelabs.paycraft.billing.resolveCheckoutLane import com.mobilebytelabs.paycraft.config.CouponDto import com.mobilebytelabs.paycraft.config.ProductDto @@ -106,6 +108,18 @@ object PayCraft { private var _activeCountry: String = CurrencyResolver.DEFAULT_COUNTRY private var _activeCurrency: String = CurrencyResolver.FALLBACK_CURRENCY + /** + * The native store's OWN localized price per plan sku (Play `formattedPrice` / StoreKit + * `displayPrice`), resolved after products load on native billing lanes. When present it is + * the truth the store charges and OVERRIDES the cloud `/config` price for the paywall + the + * per-plan currency (fixes an India buyer seeing GBP instead of the store's ₹799). Empty on + * web-checkout platforms and until the async native-price fetch completes → cloud price is used. + */ + private var nativePricesBySku: Map = emptyMap() + + /** Last applied SuiteConfig — kept so a late native-price fetch can rebuild + re-emit plans. */ + private var currentSuite: SuiteConfig? = null + /** * THE single resolved billing region — the one (country, currency) the whole paywall uses: * the displayed price AND every payment provider's checkout link read this, so a provider @@ -147,10 +161,14 @@ object PayCraft { this.initOptions = options // Decide the billing country ONCE — the single deciding point that drives the /config // locale, the displayed price, and every provider's checkout currency. Override wins, - // else the device region, else "US". (PlatformInfo reads can throw in odd test - // harnesses — same guard as the device fingerprint below.) + // else the device region, else "US". The STORE STOREFRONT (the true billing region) is a + // suspend read on the native client, so it can't be resolved here in the synchronous + // initialize(); it is folded in at fetch time (see fetchAndApplySuiteConfig, where the + // country is re-resolved with the storefront before the /config request). (PlatformInfo + // reads can throw in odd test harnesses — same guard as the device fingerprint below.) this._activeCountry = CurrencyResolver.resolveCountry( override = options.localeOverride, + storeStorefront = null, deviceCountry = runCatching { PlatformInfo.country }.getOrNull(), configLocale = null, ) @@ -245,8 +263,15 @@ object PayCraft { // may not be ready during the synchronous initialize() call (startup ordering // race). Reading PlatformInfo.country at fetch time, after app init settles, picks // up the real billing region (e.g. an Indian SIM under an en-GB phone language). + // + // Prefer the STORE STOREFRONT over the device locale: the storefront is where the + // user's Play/Apple payment account lives (the true billing region), so an India buyer + // on an en-GB phone resolves to "IN" (₹) rather than "GB" (£). storefrontCountry() is a + // native-store suspend read → null on web-checkout platforms, then device/cloud/US. + val storefront = runCatching { nativeBillingClientOrNull()?.storefrontCountry() }.getOrNull() _activeCountry = CurrencyResolver.resolveCountry( override = options.localeOverride, + storeStorefront = storefront, deviceCountry = runCatching { PlatformInfo.country }.getOrNull(), configLocale = null, ) @@ -265,6 +290,10 @@ object PayCraft { header("Authorization", "Bearer ${backend.supabaseAnonKey}") header("apikey", backend.supabaseAnonKey) header("Accept-Language", "en-$locale") + // Platform drives per-platform provider ordering server-side (migration 075): the + // `/config` edge function orders providers[] by the tenant's routing rule for this + // platform, so [primaryProvider] is the tenant's intended provider per platform. + header("X-PayCraft-Platform", runCatching { PlatformInfo.platform.lowercase() }.getOrDefault("")) } if (!response.status.isSuccess()) { PayCraftLogger.onError( @@ -280,8 +309,24 @@ object PayCraft { } val cfg = json.decodeFromString(SuiteConfig.serializer(), raw) .copy(fetchedAtEpochMillis = currentTimeMillis()) + // Fold the server's edge IP-geo (cfg.geoCountry) into the unified country resolution. + // It beats the device locale but NOT the store storefront — recomputed here so a + // web/desktop buyer (no storefront) resolves to the authoritative server country + // instead of only the device locale. Pre-fetch resolution (above) had no server signal. + _activeCountry = CurrencyResolver.resolveCountry( + override = options.localeOverride, + storeStorefront = storefront, + deviceCountry = runCatching { PlatformInfo.country }.getOrNull(), + configLocale = cfg.locale, + serverGeo = cfg.geoCountry, + ) applySuiteConfig(cfg) PayCraftLogger.onFlow("loadConfig", "cloud fetch ok — ${cfg.products.size} products") + // Now that products (and their store product ids) are loaded, ask the native store for + // its OWN localized price per product and re-apply so the paywall shows the store truth + // (e.g. ₹799 from the IN storefront) instead of the cloud /config price. No-op on + // web-checkout platforms (WebCheckoutNativeBillingClient returns null). + resolveAndApplyNativePrices(cfg) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -291,6 +336,50 @@ object PayCraft { } } + /** The Koin-resolved native billing client (Play on Android / StoreKit2 on iOS), or null. */ + private fun nativeBillingClientOrNull(): NativeBillingClient? = + runCatching { KoinPlatform.getKoinOrNull()?.getOrNull() }.getOrNull() + + /** + * The store product id for [product] on the ACTIVE native lane — Play `play_product_id` on + * Android, App Store `app_store_product_id` on iOS. Null on web-checkout platforms or when the + * dashboard did not configure a native id (→ no native price, cloud price is used). + */ + private fun storeProductIdFor(product: ProductDto): String? = when (PlatformInfo.platform.lowercase()) { + "android" -> product.playProductId + "ios" -> product.appStoreProductId + else -> null + }?.takeIf { it.isNotBlank() } + + /** + * Ask the native store for each product's OWN localized price (Play `formattedPrice` / + * StoreKit `displayPrice`), keyed by plan sku, then re-apply the suite so the paywall re-emits + * with the store truth (the USD/₹ the store will actually charge) instead of the cloud price. + * + * USD/cloud fallback is automatic: any product with no native id, or that the store can't + * price (unresolved storefront/product), is simply absent from the map → [toBillingPlans] + * keeps its cloud-resolved price (which already falls back to the USD base currency). + */ + private suspend fun resolveAndApplyNativePrices(suite: SuiteConfig) { + val client = nativeBillingClientOrNull() ?: return + val prices = mutableMapOf() + for (product in suite.products) { + val storeProductId = storeProductIdFor(product) ?: continue + val native = runCatching { client.nativeDisplayPrice(storeProductId) }.getOrNull() ?: continue + prices[product.sku] = native + } + if (prices.isNotEmpty()) { + nativePricesBySku = prices + // Re-apply the SAME suite: rebuilds config.plans with native prices and re-emits the + // flow so the collecting paywall ViewModel recomposes with the store-localized price. + applySuiteConfig(suite) + PayCraftLogger.onFlow("loadConfig", "native store prices applied for ${prices.size} products") + } + } + + /** The native store's localized price for a plan sku, if resolved. See [displayPrice] wiring. */ + internal fun nativePriceForSku(sku: String): NativeDisplayPrice? = nativePricesBySku[sku] + /** * Empty PaymentProvider used as a placeholder when [config] is populated * synchronously by [initialize] before the async cloud fetch completes. @@ -313,7 +402,8 @@ object PayCraft { // value (null on cold start) and drop the plans — and it would never re-fire, // because the StateFlow value wouldn't change again. Config-first ordering // guarantees every collector observes the freshly-resolved config. - val resolved = suite.toPayCraftConfig(backend, apiKey) + currentSuite = suite + val resolved = suite.toPayCraftConfig(backend, apiKey, nativePricesBySku) this.config = resolved // The cloud resolved prices for the active locale; capture the single currency every // provider + the displayed price now share (uniform across products for a locale). @@ -399,12 +489,14 @@ object PayCraft { /** * Start checkout for [plan]. * - * Google-Play-compliance routing (Payments policy): on **Android** for a **digital** product the - * checkout transacts through **Google Play Billing** ([BillingManager.purchaseViaPlayBilling]) — - * it NEVER opens an external Stripe/Razorpay web payment page (the "leads users to a payment - * method other than Google Play's billing system" violation). On every other platform - * (web/desktop/ios/macos) — or a genuinely physical product — it keeps the existing web-link - * path. The lane is decided by [resolveCheckoutLane], the single unit-tested decision point. + * Store-compliance routing (Payments policy): on **Android** for a **digital** product the + * checkout transacts through **Google Play Billing** ([BillingManager.purchaseViaPlayBilling]), + * and on **iOS/macOS** through **Apple StoreKit** ([BillingManager.purchaseViaStoreKit], Apple + * Guideline 3.1.1) — it NEVER opens an external Stripe/Razorpay web payment page (the Play "leads + * users to a payment method other than Google Play's billing system" / the Apple 3.1.1 "digital + * subscription must use IAP" violations). On a platform with no native store (web/desktop) — or a + * genuinely physical product — it keeps the existing web-link path. The lane is decided by + * [resolveCheckoutLane], the single unit-tested decision point. */ fun checkout(plan: BillingPlan, email: String? = null) { when (val lane = resolveCheckoutLane(PlatformInfo.platform, plan)) { @@ -413,11 +505,14 @@ object PayCraft { val url = appendCouponParam(baseUrl, appliedCoupons[plan.id]?.code) PayCraftPlatform.openUrl(url) } - // Both NativePlay and Misconfigured delegate to the billing manager: it purchases via - // Play Billing, or (misconfigured play_product_id) sets BillingState.Error WITHOUT ever - // opening the browser — the anti-steering guarantee. - is CheckoutLane.NativePlay, is CheckoutLane.Misconfigured -> - routeAndroidDigitalToPlay(plan, email, lane) + // NativePlay/NativeStoreKit/Misconfigured all delegate to the billing manager: it + // purchases via the store lane, or (misconfigured product id) sets BillingState.Error + // WITHOUT ever opening the browser — the anti-steering guarantee on both stores. + is CheckoutLane.NativePlay, + is CheckoutLane.NativeStoreKit, + is CheckoutLane.Misconfigured, + -> + routeNativeDigital(plan, email, lane) } } @@ -425,8 +520,9 @@ object PayCraft { * Checkout via a specific provider picked by the user in `ProviderBottomSheet`. * Used by the multi-provider flow; single-provider apps use [checkout] instead. * - * Applies the SAME Google-Play-compliance routing as [checkout]: on Android+digital the provider - * pick is irrelevant — the purchase still goes through Google Play Billing, never the web link. + * Applies the SAME store-compliance routing as [checkout]: on Android+digital the purchase goes + * through Google Play Billing and on iOS/macOS+digital through StoreKit — the provider pick is + * irrelevant on a native store, never the web link. */ internal fun checkoutWithProvider(plan: BillingPlan, provider: ProviderDto, email: String? = null) { when (val lane = resolveCheckoutLane(PlatformInfo.platform, plan)) { @@ -436,17 +532,24 @@ object PayCraft { val url = appendCouponParam(baseUrl, appliedCoupons[plan.id]?.code) PayCraftPlatform.openUrl(url) } - is CheckoutLane.NativePlay, is CheckoutLane.Misconfigured -> - routeAndroidDigitalToPlay(plan, email, lane) + is CheckoutLane.NativePlay, + is CheckoutLane.NativeStoreKit, + is CheckoutLane.Misconfigured, + -> + routeNativeDigital(plan, email, lane) } } /** - * Hand an Android digital checkout to Google Play Billing via the Koin-resolved [BillingManager]. - * The manager owns the billing-state flow the paywall observes (Loading → Success/Cancelled/Error) - * and enforces the anti-steering guard (blank play product id → error, never a browser fallback). + * Hand a native digital checkout to the store's in-app billing via the Koin-resolved + * [BillingManager] — Google Play Billing on Android ([BillingManager.purchaseViaPlayBilling]) or + * StoreKit on iOS/macOS ([BillingManager.purchaseViaStoreKit], Apple Guideline 3.1.1). The + * manager owns the billing-state flow the paywall observes (Loading → Success/Cancelled/Error) and + * enforces the anti-steering guard (blank product id → error, never a browser fallback). A + * [CheckoutLane.Misconfigured] is dispatched to the platform-appropriate lane so it fails closed + * with the correct store message — never a web fallback. */ - private fun routeAndroidDigitalToPlay(plan: BillingPlan, email: String?, lane: CheckoutLane) { + private fun routeNativeDigital(plan: BillingPlan, email: String?, lane: CheckoutLane) { val billingManager = KoinPlatform.getKoinOrNull()?.getOrNull() if (billingManager == null) { // No Koin graph (should never happen in a real app — the paywall itself is Koin-resolved). @@ -454,15 +557,27 @@ object PayCraft { // the exact anti-steering violation we are preventing. PayCraftLogger.onError( "checkout", - "Android digital checkout for ${plan.id} but no BillingManager in the Koin graph — " + - "load PayCraftModule + paycraftPlayBillingModule. Refusing web fallback (anti-steering).", + "native digital checkout for ${plan.id} but no BillingManager in the Koin graph — " + + "load PayCraftModule + the platform billing module. Refusing web fallback (anti-steering).", ) return } - if (lane is CheckoutLane.Misconfigured) { - PayCraftLogger.onError("checkout", "${lane.reason} for plan ${plan.id} (Android digital)") + when (lane) { + is CheckoutLane.NativePlay -> billingManager.purchaseViaPlayBilling(plan, email) + is CheckoutLane.NativeStoreKit -> billingManager.purchaseViaStoreKit(plan, email) + is CheckoutLane.Misconfigured -> { + PayCraftLogger.onError("checkout", "${lane.reason} for plan ${plan.id} (native digital)") + // Fail closed through the platform-appropriate lane so the anti-steering guard sets + // BillingState.Error with the right store message — never a web fallback. + val platform = PlatformInfo.platform + if (platform.equals("ios", ignoreCase = true) || platform.equals("macos", ignoreCase = true)) { + billingManager.purchaseViaStoreKit(plan, email) + } else { + billingManager.purchaseViaPlayBilling(plan, email) + } + } + is CheckoutLane.Web -> Unit // unreachable — Web is handled by the caller's when-branch. } - billingManager.purchaseViaPlayBilling(plan, email) } /** @@ -504,14 +619,27 @@ data class PayCraftConfig( enum class ConfigSource { Cloud, SelfHosted, Mock } +/** + * The tenant's PRIMARY provider for the active platform. The `/config` server orders + * [SuiteConfig.providers] by the tenant's per-platform routing preference (migration 075), so the + * SDK trusts that server order and takes the head rather than making its own arbitrary pick — a + * "desktop → Stripe" tenant gets Stripe first on desktop, an "android → Razorpay" tenant gets + * Razorpay first on Android. Returns null only when the tenant has zero enabled providers. + */ +internal fun SuiteConfig.primaryProvider(): ProviderDto? = providers.firstOrNull() + /** * Map a cloud-fetched [SuiteConfig] into the existing [PayCraftConfig] shape. - * Provider construction is best-effort — the first registered provider wins for the - * legacy single-provider field. Multi-provider apps consume `SuiteConfig.providers` - * directly via the bottom-sheet picker. + * The primary provider is the server-ordered head ([primaryProvider]) — the tenant's per-platform + * preference — not an arbitrary DB pick. Multi-provider apps consume `SuiteConfig.providers` + * directly (in the same server order) via the bottom-sheet picker. */ -internal fun SuiteConfig.toPayCraftConfig(backend: PayCraftBackend, apiKey: String?): PayCraftConfig { - val firstProvider = providers.firstOrNull() +internal fun SuiteConfig.toPayCraftConfig( + backend: PayCraftBackend, + apiKey: String?, + nativePricesBySku: Map = emptyMap(), +): PayCraftConfig { + val firstProvider = primaryProvider() val provider: PaymentProvider = if (firstProvider != null) { SuiteProviderAdapter(firstProvider) } else { @@ -521,7 +649,7 @@ internal fun SuiteConfig.toPayCraftConfig(backend: PayCraftBackend, apiKey: Stri supabaseUrl = backend.supabaseUrl, supabaseAnonKey = backend.supabaseAnonKey, provider = provider, - plans = products.toBillingPlans(paywall.popularPlanSku), + plans = products.toBillingPlans(paywall.popularPlanSku, nativePricesBySku), benefits = emptyList(), // benefits surface on PaywallDto.themeJsonb in cloud mode supportEmail = paywall.supportEmail ?: "support@paycraft.mobilebytesensei.com", apiKey = apiKey, @@ -533,7 +661,10 @@ internal fun SuiteConfig.toPayCraftConfig(backend: PayCraftBackend, apiKey: Stri ) } -private fun List.toBillingPlans(popularSku: String?): List { +private fun List.toBillingPlans( + popularSku: String?, + nativePricesBySku: Map = emptyMap(), +): List { val subscriptions = filter { it.type == "subscription" || it.type == "lifetime" } .sortedBy { it.displayOrder } val trials = filter { it.type == "trial" } @@ -545,14 +676,21 @@ private fun List.toBillingPlans(popularSku: String?): List null } + // Prefer the NATIVE store price (Play/StoreKit) when one was resolved for this sku — it is + // the store truth (already reflects the store storefront, e.g. ₹799 from the IN storefront) + // and its own formatted string is authoritative, overriding the cloud /config price+currency. + val nativePrice = nativePricesBySku[dto.sku] + // Resolve the display amounts. resolvedPrice wins (per-locale); fall back to // baseCurrency for tenants without a tenant_pricing row. val originalCents = dto.resolvedPrice?.amountCents ?: dto.basePriceCents - val originalCurrency = dto.resolvedPrice?.currency ?: dto.baseCurrency + val originalCurrency = nativePrice?.currencyCode ?: dto.resolvedPrice?.currency ?: dto.baseCurrency // Apply the auto-discount when discount_percent is set AND not expired. // Server-side /config already strips expired discounts (see edge function), // so by the time we land here a non-null discountPercent means it's active. + // Discounts apply to the cloud amount only; a native store price is the store's own final + // charge, so it is shown as-is (the store applies its own promotions). val discountPercent = dto.discountPercent?.takeIf { it in 1..99 } val effectiveCents = if (discountPercent != null) { (originalCents.toLong() * (100 - discountPercent) / 100).toInt() @@ -563,14 +701,14 @@ private fun List.toBillingPlans(popularSku: String?): List { + val productId = plan.playProductId + if (productId.isNullOrBlank()) { + CheckoutLane.Misconfigured("Google Play product not configured") + } else { + CheckoutLane.NativePlay(productId) + } + } + + platform.equals("ios", ignoreCase = true) || platform.equals("macos", ignoreCase = true) -> { + val productId = plan.appStoreProductId + if (productId.isNullOrBlank()) { + CheckoutLane.Misconfigured("App Store product not configured") + } else { + CheckoutLane.NativeStoreKit(productId) + } + } + + // web / desktop (or any other) digital → no native store, keep the web checkout URL. + else -> CheckoutLane.Web } } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/NativeBillingClient.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/NativeBillingClient.kt index ab6e90d..ce9deeb 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/NativeBillingClient.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/NativeBillingClient.kt @@ -23,6 +23,22 @@ data class NativePurchase( val packageName: String? = null, ) +/** + * The store's OWN localized price for a product, as reported by Google Play + * (`ProductDetails` → `formattedPrice` / `priceCurrencyCode` / `priceAmountMicros`) or StoreKit2 + * (`Product.displayPrice` / currency / `price`). This is the truth the shopper is actually charged + * in the native billing lane — it already reflects the store storefront (the region where the + * user's Play/Apple payment account lives), not the device UI locale or the cloud `/config` price. + * + * Device-free value object so `commonMain` pricing code can prefer the native price over the + * cloud-resolved one for native lanes (Android Play Billing / iOS StoreKit2). + * + * @param formatted Store-formatted, localized price string (e.g. "₹799.00", "$9.99"). + * @param currencyCode ISO 4217 currency of the store price (e.g. "INR", "USD"). + * @param amountMicros Price in micro-units of the currency (1_000_000 micros = 1 unit). + */ +data class NativeDisplayPrice(val formatted: String, val currencyCode: String, val amountMicros: Long) + /** Outcome of a native purchase attempt. */ sealed interface NativePurchaseResult { data class Success(val purchase: NativePurchase) : NativePurchaseResult @@ -74,6 +90,23 @@ interface NativeBillingClient { * the store supports it; null opens the account subscription list. */ suspend fun manageSubscription(productId: String?) + + /** + * The store's billing storefront country (ISO 3166-1 alpha-2) — Play + * `getBillingConfig().countryCode` / StoreKit `Storefront.current?.countryCode`. This is the + * region the user's Play/Apple PAYMENT ACCOUNT lives in, which is the true billing region for + * native lanes and takes precedence over the device UI locale (an Indian buyer on an en-GB + * phone should see IN pricing, not GB). Null when the store cannot report it. + */ + suspend fun storefrontCountry(): String? + + /** + * The store's OWN localized price for [productId] — Play `ProductDetails.formattedPrice` / + * StoreKit `Product.displayPrice`. Preferred over the cloud `/config` price for native lanes + * so the paywall shows exactly what the store will charge (e.g. ₹799 from the IN storefront). + * Null when the product/price is unavailable on this store. + */ + suspend fun nativeDisplayPrice(productId: String): NativeDisplayPrice? } /** @@ -101,4 +134,11 @@ class WebCheckoutNativeBillingClient : NativeBillingClient { // intentional-noop: no native subscription centre on this platform; PSP cancel is used. override suspend fun manageSubscription(productId: String?) = Unit + + // intentional-noop: no native store → no store storefront; country falls through to the + // device region / cloud locale in CurrencyResolver. + override suspend fun storefrontCountry(): String? = null + + // intentional-noop: no native store → no store-localized price; the cloud /config price is used. + override suspend fun nativeDisplayPrice(productId: String): NativeDisplayPrice? = null } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.kt new file mode 100644 index 0000000..6a65b41 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.kt @@ -0,0 +1,23 @@ +package com.mobilebytelabs.paycraft.billing + +/** + * The platform's DEFAULT native in-app-purchase client, or `null` when the + * platform has no auto-wireable native store — in which case the caller falls + * back to [WebCheckoutNativeBillingClient]. + * + * This is the seam that makes native billing work with a **commonMain-only** + * consumer integration. `PayCraftModule` binds + * `platformDefaultNativeBillingClient() ?: WebCheckoutNativeBillingClient()`, so: + * + * - **Android** → the real Google Play Billing v8 client, auto-wired from the + * Application context + foreground-Activity tracking that `PayCraftInitializer` + * already sets up. The consumer does NOT load `paycraftPlayBillingModule` or + * supply an activityProvider — just `PayCraft.initialize(apiKey)` in commonMain. + * - **iOS** → `null` for now: StoreKit2 needs the app-supplied Swift bridge, so + * iOS consumers still opt in via `paycraftStoreKit2BillingModule`. + * - **web / desktop** → `null` → web checkout (correct: no native store exists). + * + * A consumer can still override the binding explicitly (e.g. a custom + * activityProvider) by loading `paycraftPlayBillingModule` after `PayCraftModule`. + */ +expect fun platformDefaultNativeBillingClient(): NativeBillingClient? diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt index e49bfcc..089702e 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt @@ -17,6 +17,16 @@ data class SuiteConfig( val providers: List = emptyList(), val paywall: PaywallDto = PaywallDto(), val locale: String = "US", + /** + * The buyer country the PayCraft cloud resolved from the request's edge IP-country header + * (`x-vercel-ip-country` / `cf-ipcountry` / `cloudfront-viewer-country`). ISO 3166-1 alpha-2, + * or null when the hosting edge did not attach the header. Folded into the client's unified + * [com.mobilebytelabs.paycraft.CountryDetector] resolution below the store storefront and above + * the device locale — one consistent country signal on every platform. + */ + @SerialName("geo_country") val geoCountry: String? = null, + /** Provenance of [geoCountry]: `"SERVER_IP_GEO"` when resolved, `"ABSENT"` when no header. */ + @SerialName("geo_source") val geoSource: String? = null, @SerialName("cache_ttl_seconds") val cacheTtlSeconds: Int = 3600, // Set by the client on receipt; not returned by the server. @SerialName("fetched_at_epoch_millis") val fetchedAtEpochMillis: Long = 0L, @@ -106,6 +116,13 @@ data class ProviderDto( @SerialName("live_payment_links") val livePaymentLinksBySku: Map> = emptyMap(), @SerialName("supported_locales") val supportedLocales: List? = null, + /** + * The caller platform this provider was ordered for (`ios`/`android`/`desktop`/`web`), echoed + * by `/config` from the `X-PayCraft-Platform` request header (migration 075). Informational — + * the meaningful signal is the ORDER of [SuiteConfig.providers], which the SDK trusts as the + * tenant's per-platform preference. Null when the server did not tag it. + */ + @SerialName("platform") val platform: String? = null, ) /** diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt index b8b20f9..6a8bf84 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt @@ -68,6 +68,26 @@ interface BillingManager { */ fun purchaseViaPlayBilling(plan: com.mobilebytelabs.paycraft.model.BillingPlan, email: String?) + /** + * Apple StoreKit in-app-purchase lane (Apple Guideline 3.1.1 compliance). + * + * Called for an **iOS/macOS digital** checkout instead of opening a web payment page — a web + * checkout for a digital subscription on iOS is a 3.1.1 rejection. Drives [billingState]: + * Loading → then Premium / Free (user cancelled) / Error (failure OR a missing + * `app_store_product_id` — which is BLOCKED, never a browser fallback). No-op-with-error on + * platforms/builds where no native billing client is wired. + * + * Unlike [purchaseViaPlayBilling] there is no client-facing StoreKit grant endpoint today: + * entitlement truth lands server-side via the Apple App Store Server Notifications (ASSN-V2) + * webhook, so on success this reconciles through the normal server refresh path rather than an + * immediate client-side register call. + * + * @param plan the plan to purchase; its [com.mobilebytelabs.paycraft.model.BillingPlan.appStoreProductId] + * is the App Store product id. Blank/null → [BillingState.Error], never a web fallback (anti-steering). + * @param email the buyer email (already logged-in by the paywall), used as the stable app-user-id. + */ + fun purchaseViaStoreKit(plan: com.mobilebytelabs.paycraft.model.BillingPlan, email: String?) + /** Registers this device with the server and checks premium status. Replaces logIn(). */ fun registerAndLogin(email: String) diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt index 1e6d8c2..aaa552d 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt @@ -2,6 +2,7 @@ package com.mobilebytelabs.paycraft.core import com.mobilebytelabs.paycraft.PayCraft import com.mobilebytelabs.paycraft.billing.NativeBillingClient +import com.mobilebytelabs.paycraft.billing.NativePurchase import com.mobilebytelabs.paycraft.billing.NativePurchaseResult import com.mobilebytelabs.paycraft.debug.PayCraftLogger import com.mobilebytelabs.paycraft.model.BillingPlan @@ -11,6 +12,7 @@ import com.mobilebytelabs.paycraft.model.SubscriptionStatus import com.mobilebytelabs.paycraft.model.TrialInfo import com.mobilebytelabs.paycraft.model.VerificationMethod import com.mobilebytelabs.paycraft.model.toSubscriptionStatus +import com.mobilebytelabs.paycraft.network.EntitlementDto import com.mobilebytelabs.paycraft.network.OtpGateResult import com.mobilebytelabs.paycraft.network.PayCraftService import com.mobilebytelabs.paycraft.persistence.PayCraftStore @@ -142,32 +144,85 @@ class PayCraftBillingManager( override fun logIn(email: String) = registerAndLogin(email) - // ─── Google Play Billing lane (Payments-policy compliance) ───────────────── + // ─── Native in-app-purchase lanes (Payments-policy / Guideline-3.1.1 compliance) ───────────── /** Canonical states that mean the entitlement is currently premium (grace = active, D6). */ private val premiumCanonicalStates = setOf("trial", "active", "active_non_renewing", "in_grace_period") - override fun purchaseViaPlayBilling(plan: BillingPlan, email: String?) { + override fun purchaseViaPlayBilling(plan: BillingPlan, email: String?) = purchaseNative( + tag = "purchaseViaPlayBilling", + plan = plan, + email = email, + productId = plan.playProductId, + storeLabel = "Play", + notWiredError = "Google Play billing is not available on this device", + misconfiguredError = "Google Play product not configured", + // Google Play has a client-facing grant endpoint: register the purchaseToken server-side and + // reflect the reconciled entitlement immediately. + register = { purchase, resolvedProductId, appUserId -> + service.registerPlayPurchase( + purchaseToken = purchase.purchaseToken, + productId = resolvedProductId, + appUserId = appUserId, + packageName = purchase.packageName.orEmpty(), + ) + }, + ) + + override fun purchaseViaStoreKit(plan: BillingPlan, email: String?) = purchaseNative( + tag = "purchaseViaStoreKit", + plan = plan, + email = email, + productId = plan.appStoreProductId, + storeLabel = "StoreKit", + notWiredError = "App Store billing is not available on this device", + misconfiguredError = "App Store product not configured", + // No client-facing StoreKit grant endpoint today: entitlement truth lands server-side via the + // Apple App Store Server Notifications (ASSN-V2) webhook, so we skip the immediate register + // call and reconcile through the normal server path below. (Follow-up: a client-facing + // register-appstore endpoint mirroring register-play-purchase would enable instant unlock.) + register = null, + ) + + /** + * Shared native-purchase driver for both store lanes (Play Billing / StoreKit). Enforces the + * SAME fail-closed anti-steering contract on both: a missing product id or an unwired native + * client sets [BillingState.Error] and NEVER opens the web page. + * + * @param register optional server grant step (Play has one, StoreKit does not). When non-null and + * it returns a premium entitlement, premium is reflected immediately; when non-null and it + * returns null, the purchase is surfaced as "could not be verified". When null (StoreKit), the + * purchase reconciles purely through the server refresh path (ASSN-V2 already delivered truth). + */ + private fun purchaseNative( + tag: String, + plan: BillingPlan, + email: String?, + productId: String?, + storeLabel: String, + notWiredError: String, + misconfiguredError: String, + register: (suspend (purchase: NativePurchase, productId: String, appUserId: String) -> EntitlementDto?)?, + ) { val native = nativeBillingClient if (native == null) { - // No native client wired (e.g. paycraftPlayBillingModule not loaded). Fail CLOSED with an - // error — we do NOT fall back to the web page (that is the violation we prevent). + // No native client wired (e.g. the platform billing module not loaded). Fail CLOSED with + // an error — we do NOT fall back to the web page (that is the violation we prevent). PayCraftLogger.onError( - "purchaseViaPlayBilling", - "no NativeBillingClient wired for ${plan.id} — load paycraftPlayBillingModule on Android", + tag, + "no NativeBillingClient wired for ${plan.id} — load the platform billing module", ) - _billingState.value = BillingState.Error("Google Play billing is not available on this device") + _billingState.value = BillingState.Error(notWiredError) return } - val productId = plan.playProductId if (productId.isNullOrBlank()) { - // ANTI-STEERING KEYSTONE: a misconfigured product must NOT open the browser on Android. + // ANTI-STEERING KEYSTONE: a misconfigured product must NOT open the browser on a native store. PayCraftLogger.onError( - "purchaseViaPlayBilling", - "playProductId missing for ${plan.id} — refusing web fallback (Payments-policy anti-steering)", + tag, + "product id missing for ${plan.id} — refusing web fallback (store anti-steering)", ) - _billingState.value = BillingState.Error("Google Play product not configured") + _billingState.value = BillingState.Error(misconfiguredError) return } @@ -181,19 +236,16 @@ class PayCraftBillingManager( when (val result = native.purchase(productId)) { is NativePurchaseResult.Success -> { val purchase = result.purchase - PayCraftLogger.onFlow( - "purchaseViaPlayBilling", - "Play purchase OK (product=$productId) → registering with server", - ) - val entitlement = try { - service.registerPlayPurchase( - purchaseToken = purchase.purchaseToken, - productId = productId, - appUserId = appUserId, - packageName = purchase.packageName.orEmpty(), - ) - } catch (e: Exception) { - PayCraftLogger.onError("purchaseViaPlayBilling", "registerPlayPurchase failed: ${e.message}") + PayCraftLogger.onFlow(tag, "$storeLabel purchase OK (product=$productId)") + + val entitlement = if (register != null) { + try { + register(purchase, productId, appUserId) + } catch (e: Exception) { + PayCraftLogger.onError(tag, "server register failed: ${e.message}") + null + } + } else { null } @@ -204,7 +256,7 @@ class PayCraftBillingManager( if (nowPremium) { val status = SubscriptionStatus( isPremium = true, - plan = entitlement!!.productId, + plan = entitlement.productId, email = _userEmail.value, provider = entitlement.provider, expiresAt = entitlement.expiresAt?.let { millisToIso(it) }, @@ -218,19 +270,36 @@ class PayCraftBillingManager( _subscriptionActivated.emit(SubscriptionActivated(sku = status.plan, isTrial = false)) } lastObservedPremium = true - } else if (entitlement == null) { + } else if (register != null && entitlement == null) { + // A grant endpoint EXISTS but did not confirm — surface the failure. _billingState.value = BillingState.Error( "Purchase completed but could not be verified. Contact support if premium doesn't unlock.", ) } - // Then reconcile through the normal server path so the entitlement fully lands - // (task 3). refreshStatus(force=true) re-checks server truth for the device. - refreshStatus(force = true) + // Then reconcile through the normal server path so the entitlement fully lands. + if (_billingState.value is BillingState.Loading) { + // register == null (StoreKit): nothing set Premium/Error, so state is still + // Loading. refreshStatus() would skip on its Loading guard — reconcile directly + // instead so ASSN-V2-delivered truth is picked up. + val reconcileEmail = _userEmail.value + if (reconcileEmail != null) { + checkPremiumWithDeviceToken(reconcileEmail) + } else { + _billingState.value = if (_isPremium.value) { + BillingState.Premium(_subscriptionStatus.value) + } else { + BillingState.Free + } + } + } else { + // state is Premium/Error → refreshStatus re-checks server truth for the device. + refreshStatus(force = true) + } } NativePurchaseResult.Cancelled -> { - PayCraftLogger.onFlow("purchaseViaPlayBilling", "Play purchase cancelled by user") + PayCraftLogger.onFlow(tag, "$storeLabel purchase cancelled by user") // Return to the pre-purchase resting state rather than an error. _billingState.value = if (_isPremium.value) { BillingState.Premium(_subscriptionStatus.value) @@ -240,7 +309,7 @@ class PayCraftBillingManager( } is NativePurchaseResult.Failed -> { - PayCraftLogger.onError("purchaseViaPlayBilling", "Play purchase failed: ${result.message}") + PayCraftLogger.onError(tag, "$storeLabel purchase failed: ${result.message}") _billingState.value = BillingState.Error(result.message) } } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftModule.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftModule.kt index 9d2bcbe..a6137bc 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftModule.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftModule.kt @@ -3,6 +3,7 @@ package com.mobilebytelabs.paycraft.di import com.mobilebytelabs.paycraft.PayCraft import com.mobilebytelabs.paycraft.billing.NativeBillingClient import com.mobilebytelabs.paycraft.billing.WebCheckoutNativeBillingClient +import com.mobilebytelabs.paycraft.billing.platformDefaultNativeBillingClient import com.mobilebytelabs.paycraft.core.BillingManager import com.mobilebytelabs.paycraft.core.EntitlementRepository import com.mobilebytelabs.paycraft.core.PayCraftBillingManager @@ -59,9 +60,13 @@ val PayCraftModule = module { // ─── Phase 4: Store5 offline cache + restore/cancel orchestration ───────── - // Default web-checkout native client (no native store on jvm/desktop/wasmJs/js/macos — D13). - // Android/iOS consumers override this binding with the Phase-3 actual StoreKit2/Play client. - single { WebCheckoutNativeBillingClient() } + // Native billing client, resolved PER PLATFORM automatically so a + // commonMain-only consumer gets the right client with no androidMain wiring: + // Android → real Google Play Billing v8 (context + Activity auto-captured by + // PayCraftInitializer); iOS/web/desktop → web checkout (null here). + // iOS StoreKit2 + a custom Android activityProvider remain opt-in overrides via + // paycraftStoreKit2BillingModule / paycraftPlayBillingModule loaded afterwards. + single { platformDefaultNativeBillingClient() ?: WebCheckoutNativeBillingClient() } // Store5 read-through cache — Fetcher(/entitlements) + SourceOfTruth(offline last-known-good). single { diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/ProductPricing.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/ProductPricing.kt index 2140b5a..bcb9de0 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/ProductPricing.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/ProductPricing.kt @@ -1,5 +1,6 @@ package com.mobilebytelabs.paycraft.model +import com.mobilebytelabs.paycraft.billing.NativeDisplayPrice import com.mobilebytelabs.paycraft.config.SuiteConfig /** Money amount in minor units (cents/paise) + ISO 4217 currency code. */ @@ -21,16 +22,31 @@ data class Money(val amountMinor: Int, val currency: String) { val frac = absFraction.toString().padStart(2, '0') return "$major.$frac" } + + companion object { + /** + * Build a [Money] from a store price in micro-units (Play `priceAmountMicros` / StoreKit2 + * `price` × 1_000_000). Minor units (cents/paise) = micros / 10_000 (1 unit = 100 minor = + * 1_000_000 micros). E.g. ₹799.00 → 799_000_000 micros → 79_900 paise → `Money(79900, "INR")`. + */ + fun fromMicros(micros: Long, currency: String): Money = Money((micros / 10_000L).toInt(), currency) + } } /** * Resolves the price the SDK should display for [this] product in the user's locale. - * Cloud has already locale-resolved at /functions/v1/config render time via [PriceDto]; - * this is the in-app accessor that falls back to the SDK-side base price. + * + * Precedence: when a [nativePrice] is supplied (native billing lane — Android Play Billing / + * iOS StoreKit2) it is the truth the store will actually charge and WINS over the cloud price — + * this is what fixes an India buyer seeing the cloud GBP price instead of the store's ₹799. + * Otherwise the cloud has already locale-resolved at /functions/v1/config render time via + * [PriceDto]; this is the in-app accessor that falls back to the SDK-side base price. * * Returns null for [Product.Trial] — the trial card shows "Free for N days", not money. */ -fun Product.displayPrice(config: SuiteConfig): Money? { +fun Product.displayPrice(config: SuiteConfig, nativePrice: NativeDisplayPrice? = null): Money? { + if (this is Product.Trial) return null + if (nativePrice != null) return Money.fromMicros(nativePrice.amountMicros, nativePrice.currencyCode) val dto = config.products.firstOrNull { it.id == this.id } ?: return fallbackPrice() val priced = dto.resolvedPrice if (priced != null) return Money(priced.amountCents, priced.currency) diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CountryDetectorTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CountryDetectorTest.kt new file mode 100644 index 0000000..51d97e7 --- /dev/null +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CountryDetectorTest.kt @@ -0,0 +1,50 @@ +package com.mobilebytelabs.paycraft + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Locks the unified cross-platform country resolution + its provenance tags. Pins the precedence + * `store storefront → server IP-geo → device/SIM → config locale → DEFAULT_COUNTRY` so a web/desktop + * buyer (no storefront) still resolves to the authoritative server IP-geo instead of the device + * locale, and every branch reports the correct [CountryProvenance] for downstream trust decisions. + */ +class CountryDetectorTest { + + @Test fun storefrontWinsOverEverything() { + val d = CountryDetector.resolve(storefront = "IN", serverGeo = "GB", deviceSim = "US", configLocale = "fr") + assertEquals("IN", d.country) + assertEquals(CountryProvenance.AUTHORITATIVE_STORE, d.provenance) + } + + @Test fun serverGeoBeatsDeviceAndLocale() { + val d = CountryDetector.resolve(storefront = null, serverGeo = "GB", deviceSim = "US", configLocale = "fr") + assertEquals("GB", d.country) + assertEquals(CountryProvenance.SERVER_IP_GEO, d.provenance) + } + + @Test fun deviceUsedWhenNoStorefrontOrGeo() { + val d = CountryDetector.resolve(storefront = null, serverGeo = null, deviceSim = "US", configLocale = "fr") + assertEquals("US", d.country) + assertEquals(CountryProvenance.DEVICE_SIM, d.provenance) + } + + @Test fun configLocaleFallback() { + val d = CountryDetector.resolve(storefront = null, serverGeo = null, deviceSim = null, configLocale = "FR") + assertEquals("FR", d.country) + assertEquals(CountryProvenance.LOCALE_FALLBACK, d.provenance) + } + + @Test fun defaultCountryWhenAllAbsent() { + val d = CountryDetector.resolve(storefront = null, serverGeo = null, deviceSim = null, configLocale = null) + assertEquals(CurrencyResolver.DEFAULT_COUNTRY, d.country) + assertEquals(CountryProvenance.LOCALE_FALLBACK, d.provenance) + } + + @Test fun blankSignalsAreSkipped() { + // Blank storefront + blank geo must fall through to the device, not resolve to "". + val d = CountryDetector.resolve(storefront = " ", serverGeo = "", deviceSim = "IN", configLocale = "us") + assertEquals("IN", d.country) + assertEquals(CountryProvenance.DEVICE_SIM, d.provenance) + } +} diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CurrencyResolverTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CurrencyResolverTest.kt index f6d0837..3648e39 100644 --- a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CurrencyResolverTest.kt +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CurrencyResolverTest.kt @@ -14,22 +14,106 @@ class CurrencyResolverTest { private fun plan(sku: String, currency: String, rank: Int) = BillingPlan(id = sku, name = sku, price = "x", interval = "month", rank = rank, currency = currency) - // ── resolveCountry: override → device → configLocale → "US" ────────────────────────── + // ── resolveCountry: override → store storefront → device → configLocale → "US" ──────── @Test fun country_overrideWins() { - assertEquals("GB", CurrencyResolver.resolveCountry(override = "GB", deviceCountry = "IN", configLocale = "US")) + assertEquals( + "GB", + CurrencyResolver.resolveCountry( + override = "GB", + storeStorefront = "IN", + deviceCountry = "IN", + configLocale = "US", + ), + ) } @Test fun country_deviceWhenNoOverride() { - assertEquals("IN", CurrencyResolver.resolveCountry(override = null, deviceCountry = "IN", configLocale = "US")) + assertEquals( + "IN", + CurrencyResolver.resolveCountry( + override = null, + storeStorefront = null, + deviceCountry = "IN", + configLocale = "US", + ), + ) } @Test fun country_configLocaleWhenNoOverrideOrDevice() { - assertEquals("DE", CurrencyResolver.resolveCountry(override = null, deviceCountry = null, configLocale = "DE")) + assertEquals( + "DE", + CurrencyResolver.resolveCountry( + override = null, + storeStorefront = null, + deviceCountry = null, + configLocale = "DE", + ), + ) } @Test fun country_defaultsToUS() { - assertEquals("US", CurrencyResolver.resolveCountry(override = " ", deviceCountry = null, configLocale = null)) + assertEquals( + "US", + CurrencyResolver.resolveCountry( + override = " ", + storeStorefront = null, + deviceCountry = null, + configLocale = null, + ), + ) + } + + // ── store storefront (the true billing region) wins over the device UI locale ───────── + + @Test fun country_storefrontWinsOverDevice() { + // The paywall-currency bug: India buyer (IN storefront) on an en-GB phone (GB device + // locale) must resolve to IN so pricing is ₹/INR, never GB/GBP. + assertEquals( + "IN", + CurrencyResolver.resolveCountry( + override = null, + storeStorefront = "IN", + deviceCountry = "GB", + configLocale = "US", + ), + ) + } + + @Test fun country_overrideWinsOverStorefront() { + assertEquals( + "US", + CurrencyResolver.resolveCountry( + override = "US", + storeStorefront = "IN", + deviceCountry = "GB", + configLocale = "DE", + ), + ) + } + + @Test fun country_storefrontNullFallsThroughToDevice() { + assertEquals( + "GB", + CurrencyResolver.resolveCountry( + override = null, + storeStorefront = " ", + deviceCountry = "GB", + configLocale = "US", + ), + ) + } + + @Test fun country_allNullDefaultsToUS() { + assertEquals( + "US", + CurrencyResolver.resolveCountry( + override = null, + storeStorefront = null, + deviceCountry = null, + configLocale = null, + ), + ) } // ── resolveCurrency: one currency for the whole paywall ────────────────────────────── diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ProviderSelectionTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ProviderSelectionTest.kt new file mode 100644 index 0000000..645c8b3 --- /dev/null +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ProviderSelectionTest.kt @@ -0,0 +1,33 @@ +package com.mobilebytelabs.paycraft + +import com.mobilebytelabs.paycraft.config.ProviderDto +import com.mobilebytelabs.paycraft.config.SuiteConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Locks that the SDK trusts the SERVER's per-platform provider ordering (migration 075) instead of + * making an arbitrary pick. `/config` orders `providers[]` by the tenant's platform routing rules, + * so [primaryProvider] must return the head in that server order — a "desktop → Stripe" tenant gets + * Stripe first on desktop, an "android → Razorpay" tenant gets Razorpay first on Android. + */ +class ProviderSelectionTest { + + private fun suite(vararg providers: String) = + SuiteConfig(tenantId = "t", providers = providers.map { ProviderDto(provider = it) }) + + @Test fun primaryFollowsServerOrder_stripeFirst() { + assertEquals("stripe", suite("stripe", "razorpay").primaryProvider()?.provider) + } + + @Test fun primaryFollowsServerOrder_razorpayFirst() { + // Same providers, server-reordered for this platform: the SDK must follow the server order, + // not fall back to a first-registered / alphabetical pick. + assertEquals("razorpay", suite("razorpay", "stripe").primaryProvider()?.provider) + } + + @Test fun primaryNullWhenNoProviders() { + assertNull(suite().primaryProvider()) + } +} diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/billing/CheckoutRoutingTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/billing/CheckoutRoutingTest.kt index c0e4b99..d1c41bd 100644 --- a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/billing/CheckoutRoutingTest.kt +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/billing/CheckoutRoutingTest.kt @@ -10,19 +10,25 @@ import kotlin.test.assertIs * decides whether an Android digital checkout goes through Google Play Billing or falls back to a * web payment page — the exact decision that got a consumer app flagged when it opened Stripe. * - * The three enforced cases (VERIFY): Android+digital+playProductId → native; a non-Android - * platform → web (openUrl); Android+digital with a missing playProductId → BLOCKED (no web - * fallback, error). Plus: an Android PHYSICAL good is still allowed the web lane. + * The enforced cases (VERIFY): Android+digital+playProductId → Google Play native; iOS/macOS+ + * digital+appStoreProductId → StoreKit native (Apple Guideline 3.1.1); web/desktop → web (openUrl); + * a native-store digital good with a missing product id → BLOCKED (no web fallback, error). Plus: a + * PHYSICAL good is still allowed the web lane on every platform. */ class CheckoutRoutingTest { - private fun plan(playProductId: String? = "paycraft_monthly", isDigital: Boolean = true) = BillingPlan( + private fun plan( + playProductId: String? = "paycraft_monthly", + appStoreProductId: String? = "com.paycraft.monthly", + isDigital: Boolean = true, + ) = BillingPlan( id = "monthly", name = "Monthly", price = "$9.99", interval = "month", rank = 0, playProductId = playProductId, + appStoreProductId = appStoreProductId, isDigital = isDigital, ) @@ -33,13 +39,37 @@ class CheckoutRoutingTest { assertEquals("paycraft_monthly", native.productId) } + @Test + fun iosDigitalWithAppStoreProductId_routesToNativeStoreKit() { + // Apple Guideline 3.1.1: an iOS digital subscription MUST transact through StoreKit IAP, + // never a web payment page. + val lane = resolveCheckoutLane(platform = "ios", plan = plan(appStoreProductId = "com.paycraft.monthly")) + val native = assertIs(lane) + assertEquals("com.paycraft.monthly", native.productId) + } + + @Test + fun macosDigitalWithAppStoreProductId_routesToNativeStoreKit() { + // macOS shares the App Store / StoreKit lane with iOS. + val lane = resolveCheckoutLane(platform = "macos", plan = plan(appStoreProductId = "com.paycraft.monthly")) + val native = assertIs(lane) + assertEquals("com.paycraft.monthly", native.productId) + } + + @Test + fun iosDigitalWithMissingAppStoreProductId_isBlockedNotWeb() { + // ANTI-STEERING (Apple 3.1.1): a misconfigured product must NOT fall back to the browser on iOS. + assertIs(resolveCheckoutLane("ios", plan(appStoreProductId = null))) + assertIs(resolveCheckoutLane("ios", plan(appStoreProductId = ""))) + assertIs(resolveCheckoutLane("ios", plan(appStoreProductId = " "))) + assertIs(resolveCheckoutLane("macos", plan(appStoreProductId = null))) + } + @Test fun webPlatform_routesToWebCheckout() { - // A non-Android platform keeps the existing web payment link (openUrl path). + // A platform with no native store keeps the existing web payment link (openUrl path). assertIs(resolveCheckoutLane(platform = "web", plan = plan())) assertIs(resolveCheckoutLane(platform = "desktop", plan = plan())) - assertIs(resolveCheckoutLane(platform = "ios", plan = plan())) - assertIs(resolveCheckoutLane(platform = "macos", plan = plan())) } @Test @@ -51,13 +81,15 @@ class CheckoutRoutingTest { } @Test - fun androidPhysicalGood_isAllowedWebLane() { - // A genuinely physical product is permitted the external payment page even on Android. + fun physicalGood_isAllowedWebLane() { + // A genuinely physical product is permitted the external payment page on every platform. assertIs(resolveCheckoutLane("android", plan(isDigital = false))) + assertIs(resolveCheckoutLane("ios", plan(isDigital = false))) } @Test fun platformMatchIsCaseInsensitive() { assertIs(resolveCheckoutLane("Android", plan())) + assertIs(resolveCheckoutLane("iOS", plan())) } } diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManagerTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManagerTest.kt index 5835b12..4b46094 100644 --- a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManagerTest.kt +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManagerTest.kt @@ -1,6 +1,7 @@ package com.mobilebytelabs.paycraft.core import com.mobilebytelabs.paycraft.billing.NativeBillingClient +import com.mobilebytelabs.paycraft.billing.NativeDisplayPrice import com.mobilebytelabs.paycraft.billing.NativePurchase import com.mobilebytelabs.paycraft.billing.NativePurchaseResult import com.mobilebytelabs.paycraft.model.BillingPlan @@ -180,15 +181,21 @@ class PayCraftBillingManagerTest { override suspend fun sync() = Unit override suspend fun restore(): List = emptyList() override suspend fun manageSubscription(productId: String?) = Unit + override suspend fun storefrontCountry(): String? = null + override suspend fun nativeDisplayPrice(productId: String): NativeDisplayPrice? = null } - private fun digitalPlan(playProductId: String?) = BillingPlan( + private fun digitalPlan( + playProductId: String? = "paycraft_monthly", + appStoreProductId: String? = "com.paycraft.monthly", + ) = BillingPlan( id = "monthly", name = "Monthly", price = "$9.99", interval = "month", rank = 0, playProductId = playProductId, + appStoreProductId = appStoreProductId, isDigital = true, ) @@ -242,6 +249,56 @@ class PayCraftBillingManagerTest { assertIs(manager.billingState.value) } + // ─── Apple StoreKit anti-steering guard (Guideline 3.1.1 keystone) ───────── + + @Test + fun purchaseViaStoreKit_missingAppStoreProductId_setsErrorAndNeverLaunchesPurchase() { + val native = FakeNativeBillingClient() + val manager = PayCraftBillingManager( + service = FakePayCraftService(), + store = FakePayCraftStore(cached = null, lastSynced = 0L, email = null), + nativeBillingClient = native, + ) + + // A digital product with NO app_store_product_id must be BLOCKED — not routed to the store, + // and (by the caller contract) not to the browser either (Apple 3.1.1 anti-steering). + manager.purchaseViaStoreKit(digitalPlan(appStoreProductId = null), email = "user@example.com") + + val state = assertIs(manager.billingState.value) + assertEquals("App Store product not configured", state.message) + assertFalse(native.purchaseCalled, "must not launch the store flow for a misconfigured product") + } + + @Test + fun purchaseViaStoreKit_blankAppStoreProductId_isBlocked() { + val native = FakeNativeBillingClient() + val manager = PayCraftBillingManager( + service = FakePayCraftService(), + store = FakePayCraftStore(cached = null, lastSynced = 0L, email = null), + nativeBillingClient = native, + ) + + manager.purchaseViaStoreKit(digitalPlan(appStoreProductId = " "), email = null) + + assertIs(manager.billingState.value) + assertFalse(native.purchaseCalled) + } + + @Test + fun purchaseViaStoreKit_noNativeClientWired_failsClosedWithError() { + // No NativeBillingClient (StoreKit module not loaded) → fail closed with an error, + // NEVER a silent web fallback. + val manager = PayCraftBillingManager( + service = FakePayCraftService(), + store = FakePayCraftStore(cached = null, lastSynced = 0L, email = null), + nativeBillingClient = null, + ) + + manager.purchaseViaStoreKit(digitalPlan(appStoreProductId = "com.paycraft.monthly"), email = null) + + assertIs(manager.billingState.value) + } + // ─── Cache-driven premium application (applyCachedStatus) ────────────────── @Test diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/model/ProductTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/model/ProductTest.kt index 70c41ba..7ce7db6 100644 --- a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/model/ProductTest.kt +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/model/ProductTest.kt @@ -1,6 +1,9 @@ package com.mobilebytelabs.paycraft.model +import com.mobilebytelabs.paycraft.billing.NativeDisplayPrice +import com.mobilebytelabs.paycraft.config.PriceDto import com.mobilebytelabs.paycraft.config.ProductDto +import com.mobilebytelabs.paycraft.config.SuiteConfig import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails @@ -127,7 +130,54 @@ class ProductTest { attachesToProductId = null, ) // SuiteConfig with no products → trial still returns null per contract - val config = com.mobilebytelabs.paycraft.config.SuiteConfig(tenantId = "t1") + val config = SuiteConfig(tenantId = "t1") assertNull(trial.displayPrice(config)) } + + private fun monthlySub() = Product.Subscription( + id = "p1", + sku = "sub-monthly", + displayName = "Monthly", + displayOrder = 0, + interval = Product.Subscription.Interval.MONTH, + basePrice = Money(999, "USD"), + ) + + private fun cloudGbpConfig() = SuiteConfig( + tenantId = "t1", + products = listOf( + ProductDto( + id = "p1", + sku = "sub-monthly", + type = "subscription", + displayName = "Monthly", + interval = "month", + basePriceCents = 999, + baseCurrency = "USD", + displayOrder = 0, + // The bug: cloud resolves a GBP price for a GB device locale. + resolvedPrice = PriceDto(amountCents = 599, currency = "GBP", source = "locale"), + ), + ), + ) + + @Test + fun displayPrice_prefersNativePrice_overCloud() { + // Native store price (₹799.00 = 799_000_000 micros) is the store truth and must WIN over + // the cloud GBP price → Money(79900 paise, INR). This is the paywall-currency fix. + val native = NativeDisplayPrice(formatted = "₹799.00", currencyCode = "INR", amountMicros = 799_000_000L) + assertEquals(Money(79900, "INR"), monthlySub().displayPrice(cloudGbpConfig(), native)) + } + + @Test + fun displayPrice_usesCloud_whenNativePriceNull() { + // No native price (web-checkout lane / unresolved) → existing cloud-resolved behavior. + assertEquals(Money(599, "GBP"), monthlySub().displayPrice(cloudGbpConfig(), nativePrice = null)) + } + + @Test + fun displayPrice_fallsBackToBasePrice_whenNoCloudAndNoNative() { + // No products in config, no native price → SDK-side base price (USD). + assertEquals(Money(999, "USD"), monthlySub().displayPrice(SuiteConfig(tenantId = "t1"))) + } } diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/testsupport/EntitlementTestSupport.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/testsupport/EntitlementTestSupport.kt index 275d663..8fc602e 100644 --- a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/testsupport/EntitlementTestSupport.kt +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/testsupport/EntitlementTestSupport.kt @@ -1,6 +1,7 @@ package com.mobilebytelabs.paycraft.testsupport import com.mobilebytelabs.paycraft.billing.NativeBillingClient +import com.mobilebytelabs.paycraft.billing.NativeDisplayPrice import com.mobilebytelabs.paycraft.billing.NativePurchase import com.mobilebytelabs.paycraft.billing.NativePurchaseResult import com.mobilebytelabs.paycraft.model.Entitlement @@ -99,6 +100,9 @@ class SpyNativeBillingClient : NativeBillingClient { manageCalls++ manageProductIds += productId } + + override suspend fun storefrontCountry(): String? = null + override suspend fun nativeDisplayPrice(productId: String): NativeDisplayPrice? = null } /** Build a wire [EntitlementDto] (epoch-millis timestamps) for tests. */ diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftRestoreContentTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftRestoreContentTest.kt index 8dbf4f4..108fbcb 100644 --- a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftRestoreContentTest.kt +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftRestoreContentTest.kt @@ -75,6 +75,7 @@ class PayCraftRestoreContentTest { override fun logIn(email: String) = registerAndLogin(email) override fun purchaseViaPlayBilling(plan: BillingPlan, email: String?) { /* no-op in tests */ } + override fun purchaseViaStoreKit(plan: BillingPlan, email: String?) { /* no-op in tests */ } override suspend fun checkTrialEligibility(): Boolean = true diff --git a/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.ios.kt b/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.ios.kt new file mode 100644 index 0000000..f1a9454 --- /dev/null +++ b/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.ios.kt @@ -0,0 +1,9 @@ +package com.mobilebytelabs.paycraft.billing + +/** + * iOS has no AUTO-wireable native client: StoreKit2 requires the app-supplied + * Swift bridge (`StoreKit2Bridge`), so iOS consumers opt in explicitly via + * `paycraftStoreKit2BillingModule(bridge)`. Until then the caller falls back to + * web checkout. + */ +actual fun platformDefaultNativeBillingClient(): NativeBillingClient? = null diff --git a/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2Bridge.kt b/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2Bridge.kt index 4b67773..c835980 100644 --- a/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2Bridge.kt +++ b/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2Bridge.kt @@ -29,8 +29,32 @@ interface StoreKit2Bridge { /** `AppStore.showManageSubscriptions(in:)` — the StoreKit2 native manage/cancel sheet (D7). */ suspend fun showManageSubscriptions() + + /** + * `Storefront.current?.countryCode` — the App Store storefront the signed-in Apple ID buys + * from (the true billing region). Null when unavailable. + */ + suspend fun storefrontCountry(): String? + + /** + * `Product.products(for:)` → the store's own localized price for [productId]: + * `Product.displayPrice` + `priceFormatStyle.currencyCode` + `price` (Decimal → micros). + * Null when the product is unavailable in the current storefront. + */ + suspend fun displayPrice(productId: String): StoreKit2Price? } +/** + * One StoreKit2 `Product`'s localized price, flattened to device-free primitives so `commonMain` + * pricing can consume it without a StoreKit dependency. Mirrors + * [com.mobilebytelabs.paycraft.billing.NativeDisplayPrice]. + * + * @param formatted `Product.displayPrice` — the store-formatted localized string. + * @param currencyCode `Product.priceFormatStyle.currencyCode` — ISO 4217. + * @param amountMicros `Product.price` (Decimal) scaled to micro-units (× 1_000_000). + */ +data class StoreKit2Price(val formatted: String, val currencyCode: String, val amountMicros: Long) + /** * One verified StoreKit2 `Transaction`, flattened to device-free primitives so `commonMain` * reconciliation can consume it without a StoreKit dependency. diff --git a/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2NativeBillingClient.ios.kt b/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2NativeBillingClient.ios.kt index 1e9e50f..8742e06 100644 --- a/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2NativeBillingClient.ios.kt +++ b/cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/StoreKit2NativeBillingClient.ios.kt @@ -54,6 +54,13 @@ class StoreKit2NativeBillingClient(private val bridge: StoreKit2Bridge) : Native UIApplication.sharedApplication.openURL(url) } + override suspend fun storefrontCountry(): String? = bridge.storefrontCountry() + + override suspend fun nativeDisplayPrice(productId: String): NativeDisplayPrice? = + bridge.displayPrice(productId)?.let { + NativeDisplayPrice(formatted = it.formatted, currencyCode = it.currencyCode, amountMicros = it.amountMicros) + } + private fun StoreKit2Transaction.toNativePurchase(): NativePurchase = NativePurchase( productId = productId, purchaseToken = jwsRepresentation, diff --git a/cmp-paycraft/src/iosMain/swift/PayCraftStoreKit2.swift b/cmp-paycraft/src/iosMain/swift/PayCraftStoreKit2.swift index b48b5a4..8d392bf 100644 --- a/cmp-paycraft/src/iosMain/swift/PayCraftStoreKit2.swift +++ b/cmp-paycraft/src/iosMain/swift/PayCraftStoreKit2.swift @@ -7,7 +7,9 @@ // `AppStore`) is called; it conforms to the Kotlin `StoreKit2Bridge` protocol (exported into the // shared KMP framework header) and is injected from the iOS app via // `paycraftStoreKit2BillingModule(bridge:)`. `StoreKit2NativeBillingClient` (Kotlin) consumes only -// the protocol, keeping the reconciliation/restore code device-free and unit-testable. +// the protocol, keeping the reconciliation/restore code device-free and unit-testable. It is also +// the one place `Storefront.current` (billing region) and `Product.displayPrice` (store-localized +// price) are read for the paywall currency fix. // // WIRING (consuming iOS app): // 1. Add this file to the app's Xcode target (it needs the app's StoreKit entitlement). @@ -113,6 +115,43 @@ public final class PayCraftStoreKit2: NSObject, StoreKit2Bridge { } } + // MARK: storefrontCountry() -> String? + + public func storefrontCountry(completionHandler: @escaping (String?, Error?) -> Void) { + Task { + // `Storefront.current` is async — it resolves the storefront the signed-in Apple ID + // buys from (the true billing region), independent of the device UI locale. + let storefront = await Storefront.current + completionHandler(storefront?.countryCode, nil) + } + } + + // MARK: displayPrice(productId:) -> StoreKit2Price? + + public func displayPrice(productId: String, completionHandler: @escaping (StoreKit2Price?, Error?) -> Void) { + Task { + do { + let products = try await Product.products(for: [productId]) + guard let product = products.first else { + completionHandler(nil, nil) + return + } + // `price` is a Decimal in the storefront currency; scale to integer micro-units. + let micros = NSDecimalNumber(decimal: product.price * Decimal(1_000_000)).int64Value + completionHandler( + StoreKit2Price( + formatted: product.displayPrice, + currencyCode: product.priceFormatStyle.currencyCode, + amountMicros: micros + ), + nil + ) + } catch { + completionHandler(nil, error) + } + } + } + // MARK: - Helpers private func checkVerified(_ result: VerificationResult) throws -> T { diff --git a/cmp-paycraft/src/jsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.js.kt b/cmp-paycraft/src/jsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.js.kt new file mode 100644 index 0000000..f4377fb --- /dev/null +++ b/cmp-paycraft/src/jsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.js.kt @@ -0,0 +1,4 @@ +package com.mobilebytelabs.paycraft.billing + +/** Browser/JS has no native app store — the caller uses web checkout. */ +actual fun platformDefaultNativeBillingClient(): NativeBillingClient? = null diff --git a/cmp-paycraft/src/jvmMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.jvm.kt b/cmp-paycraft/src/jvmMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.jvm.kt new file mode 100644 index 0000000..5157a77 --- /dev/null +++ b/cmp-paycraft/src/jvmMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.jvm.kt @@ -0,0 +1,4 @@ +package com.mobilebytelabs.paycraft.billing + +/** Desktop/JVM has no native app store — the caller uses web checkout. */ +actual fun platformDefaultNativeBillingClient(): NativeBillingClient? = null diff --git a/cmp-paycraft/src/jvmTest/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClientJvmTest.kt b/cmp-paycraft/src/jvmTest/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClientJvmTest.kt new file mode 100644 index 0000000..55a81ec --- /dev/null +++ b/cmp-paycraft/src/jvmTest/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClientJvmTest.kt @@ -0,0 +1,17 @@ +package com.mobilebytelabs.paycraft.billing + +import kotlin.test.Test +import kotlin.test.assertNull + +/** + * On a web/desktop platform there is no native app store, so the platform + * default is null and PayCraftModule falls back to WebCheckoutNativeBillingClient. + * (The Android actual returns the real PlayBillingNativeClient — verified by the + * device/integration build, not a JVM unit test since it needs a Context.) + */ +class PlatformNativeBillingClientJvmTest { + @Test + fun jvm_has_no_native_store_falls_back_to_web_checkout() { + assertNull(platformDefaultNativeBillingClient()) + } +} diff --git a/cmp-paycraft/src/wasmJsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.wasmJs.kt b/cmp-paycraft/src/wasmJsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.wasmJs.kt new file mode 100644 index 0000000..0bef968 --- /dev/null +++ b/cmp-paycraft/src/wasmJsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.wasmJs.kt @@ -0,0 +1,4 @@ +package com.mobilebytelabs.paycraft.billing + +/** Browser/WasmJS has no native app store — the caller uses web checkout. */ +actual fun platformDefaultNativeBillingClient(): NativeBillingClient? = null diff --git a/dashboard/__tests__/lib/appstore-product-sync.test.ts b/dashboard/__tests__/lib/appstore-product-sync.test.ts new file mode 100644 index 0000000..ebee3e3 --- /dev/null +++ b/dashboard/__tests__/lib/appstore-product-sync.test.ts @@ -0,0 +1,67 @@ +/** + * Unit test for `lib/appstore-product-sync.ts`. + * + * Regression focus (2026-07-25 production incident): `subscriptions.create` + * failed with 409 ENTITY_ERROR.RELATIONSHIP.UNKNOWN because the create body + * keyed the group relationship as `subscriptionGroup`. App Store Connect keys + * it `group` (linking a `subscriptionGroups` resource). This asserts the + * create body uses `group` and NOT `subscriptionGroup`. + * + * appStoreConnectToken is mocked (no real ES256 signing); global fetch is + * mocked and routed by URL, and the create-call body is inspected directly. + */ + +jest.mock("@/lib/store-jwt", () => ({ + appStoreConnectToken: jest.fn(() => "fake-asc-token"), +})) + +import { syncProductToAppStore } from "@/lib/appstore-product-sync" + +const CREDS = { keyId: "2X9R4HXF34", issuerId: "57246542-96fe-1a63-...", bundleId: "com.sensei.social", privateKeyP8: "test-placeholder-p8-mocked" } + +function res(body: unknown, ok = true, status = 200) { + return { ok, status, json: async () => body, text: async () => JSON.stringify(body) } +} + +function installFetch() { + const fetchMock = jest.fn(async (url: unknown, init: any) => { + const u = String(url) + const method = init?.method ?? "GET" + if (u.includes("/v1/apps?filter[bundleId]")) return res({ data: [{ id: "APP1" }] }) + if (u.includes("/subscriptionGroups?limit=200")) return res({ data: [] }) // none → create + if (u.includes("/v1/subscriptionGroups") && method === "POST") return res({ data: { id: "GROUP1" } }) + if (u.includes("/subscriptions?filter[productId]")) return res({ data: [] }) // not found + if (u.endsWith("/v1/subscriptions") && method === "POST") return res({ data: { id: "SUB1" } }) + if (u.includes("/pricePoints")) return res({ data: [{ id: "PP1", attributes: { customerPrice: "9.99" } }] }) + if (u.includes("/v1/subscriptionPrices") && method === "POST") return res({ data: { id: "PRICE1" } }) + return res({ data: [] }) + }) + ;(global as unknown as { fetch: unknown }).fetch = fetchMock + return fetchMock +} + +beforeEach(() => jest.clearAllMocks()) + +test("subscription create keys the group relationship as `group` (not `subscriptionGroup`)", async () => { + const fetchMock = installFetch() + + const result = await syncProductToAppStore( + CREDS, + "prod-1", + "pro-monthly", + "Pro Monthly", + "month", + [{ currency: "USD", amountCents: 999 }], + ) + + // Find the POST to /v1/subscriptions and inspect its body. + const createCall = fetchMock.mock.calls.find( + ([u, init]) => String(u).endsWith("/v1/subscriptions") && (init as any)?.method === "POST", + ) + expect(createCall).toBeDefined() + const body = JSON.parse((createCall![1] as any).body as string) + expect(body.data.relationships.group).toEqual({ data: { type: "subscriptionGroups", id: "GROUP1" } }) + expect(body.data.relationships.subscriptionGroup).toBeUndefined() + expect(result.created).toBe(true) + expect(result.subscriptionResourceId).toBe("SUB1") +}) diff --git a/dashboard/__tests__/lib/checkout-router.platform.test.ts b/dashboard/__tests__/lib/checkout-router.platform.test.ts new file mode 100644 index 0000000..2c96dc4 --- /dev/null +++ b/dashboard/__tests__/lib/checkout-router.platform.test.ts @@ -0,0 +1,41 @@ +/** + * Unit test for the per-platform routing dimension (migration 075 / AC7). + * + * The checkout router matches a routing rule to a caller platform via `platformMatches`: a rule + * fires when it targets that exact platform OR is the "any"/null wildcard, and a platform-specific + * rule never fires on a different platform. This locks that a "desktop → Stripe" rule selects on + * desktop and is ignored on iOS. + */ + +import { platformMatches } from "@/lib/checkout-router" + +describe("platformMatches — per-platform routing (migration 075)", () => { + it("a desktop-specific rule matches on desktop", () => { + expect(platformMatches("desktop", "desktop")).toBe(true) + }) + + it("a desktop-specific rule is IGNORED on iOS", () => { + expect(platformMatches("desktop", "ios")).toBe(false) + }) + + it('"any" rules match every platform', () => { + expect(platformMatches("any", "ios")).toBe(true) + expect(platformMatches("any", "android")).toBe(true) + expect(platformMatches("any", null)).toBe(true) + }) + + it("null/undefined rule platform is treated as the wildcard", () => { + expect(platformMatches(null, "web")).toBe(true) + expect(platformMatches(undefined, "web")).toBe(true) + }) + + it("a platform-specific rule does not fire when the caller platform is unknown", () => { + expect(platformMatches("android", null)).toBe(false) + }) + + it("desktop → Stripe end-to-end: selected on desktop, skipped on ios", () => { + const rule = { platform: "desktop", priority_methods: ["stripe_card"] } + expect(platformMatches(rule.platform, "desktop") ? rule.priority_methods[0] : null).toBe("stripe_card") + expect(platformMatches(rule.platform, "ios") ? rule.priority_methods[0] : null).toBeNull() + }) +}) diff --git a/dashboard/__tests__/lib/googleplay-product-sync.test.ts b/dashboard/__tests__/lib/googleplay-product-sync.test.ts new file mode 100644 index 0000000..d5d7179 --- /dev/null +++ b/dashboard/__tests__/lib/googleplay-product-sync.test.ts @@ -0,0 +1,203 @@ +/** + * Unit tests for `lib/googleplay-product-sync.ts`. + * + * Regression focus (2026-07-24 production incident): a tenant whose pricing + * matrix carried two prices that both resolve to the SAME Play region (the + * currency→region map is many-to-one — every euro-zone price → "DE") caused + * `subscriptions.create` to fail with 400 "Region code DE is duplicated." + * The create body's basePlans[].regionalConfigs MUST carry each regionCode at + * most once. First price for a region wins, deterministically. + * + * `playAccessToken` is mocked (no real JWT grant); global fetch is mocked and + * the create-call body is inspected directly from the mock call history. + */ + +jest.mock("@/lib/store-jwt", () => ({ + playAccessToken: jest.fn(async () => "fake-play-token"), +})) + +import { syncProductToGooglePlay } from "@/lib/googleplay-product-sync" + +// playAccessToken is mocked, so the private_key is only JSON-parsed, never used +// to sign — a plain placeholder keeps the shape without tripping secret scanners. +const SA_JSON = JSON.stringify({ + client_email: "sa@example.iam.gserviceaccount.com", + private_key: "test-placeholder-signing-key-mocked", + token_uri: "https://oauth2.googleapis.com/token", +}) + +/** GET probe → 404 (absent), POST create → 200. Returns the fetch mock. */ +function mockCreatePath(opts: { activateOk?: boolean } = {}) { + const activateOk = opts.activateOk ?? true + const fetchMock = jest + .fn() + // 1) GET subscriptions.get → 404 (not found → create branch) + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => "not found" }) + // 2) POST subscriptions.create → 200 ok + .mockResolvedValueOnce({ ok: true, status: 200, text: async () => "{}" }) + // 3) POST basePlans:activate → ok (or 400 app-not-published) + .mockResolvedValueOnce( + activateOk + ? { ok: true, status: 200, text: async () => "{}" } + : { + ok: false, + status: 400, + text: async () => + JSON.stringify({ error: { code: 400, message: "The app is not published.", status: "FAILED_PRECONDITION" } }), + }, + ) + ;(global as unknown as { fetch: unknown }).fetch = fetchMock + return fetchMock +} + +/** Pull the JSON body of the POST create call (the 2nd fetch invocation). */ +function createBodyFrom(fetchMock: jest.Mock): any { + const [, init] = fetchMock.mock.calls[1] + return JSON.parse(init.body as string) +} + +beforeEach(() => jest.clearAllMocks()) + +test("collapses duplicate-region prices to a single regionalConfig (DE-duplicate regression)", async () => { + const fetchMock = mockCreatePath() + + await syncProductToGooglePlay( + { serviceAccountJson: SA_JSON, packageName: "com.example.app" }, + "prod-1", + "pro-monthly", + "Pro Monthly", + "month", + [ + { currency: "USD", amountCents: 999 }, + { currency: "EUR", amountCents: 899 }, // → DE + { currency: "EUR", amountCents: 950 }, // → DE again (must be dropped) + ], + ) + + const body = createBodyFrom(fetchMock) + const regions: string[] = body.basePlans[0].regionalConfigs.map( + (c: any) => c.regionCode, + ) + // DE appears exactly once; first EUR price (899) wins. + expect(regions).toEqual(["US", "DE"]) + const de = body.basePlans[0].regionalConfigs.find((c: any) => c.regionCode === "DE") + expect(de.price.units).toBe("8") + expect(de.price.nanos).toBe(990000000) // 99 cents → 0.99 → 990,000,000 nanos +}) + +test("sanitizes hyphenated SKUs to a Play-legal product id (malformed-id regression)", async () => { + const fetchMock = mockCreatePath() + + await syncProductToGooglePlay( + { serviceAccountJson: SA_JSON, packageName: "com.example.app" }, + "prod-3", + "pro-quarterly", // hyphen is illegal in a Play subscription id + "Pro Quarterly", + "quarter", + [{ currency: "USD", amountCents: 2847 }], + ) + + // The create body's productId — and the productId query param on both the GET + // probe and the POST create — must be hyphen-free (underscore-substituted). + const body = createBodyFrom(fetchMock) + expect(body.productId).toBe("pro_quarterly") + expect(body.productId).not.toMatch(/-/) + const getUrl = fetchMock.mock.calls[0][0] as string + const postUrl = fetchMock.mock.calls[1][0] as string + expect(getUrl).toContain("/subscriptions/pro_quarterly") + expect(postUrl).toContain("productId=pro_quarterly") + // Base-plan ids DO allow hyphens, so the derived base plan keeps its shape. + expect(body.basePlans[0].basePlanId).toBe("pro-quarterly-autorenew") +}) + +test("treats IDR/COP as whole-unit (zero-decimal) so Play prices are not ÷100 (below-min regression)", async () => { + const fetchMock = mockCreatePath() + + await syncProductToGooglePlay( + { serviceAccountJson: SA_JSON, packageName: "com.example.app" }, + "prod-idr", + "pro-monthly", + "Pro Monthly", + "month", + [ + { currency: "IDR", amountCents: 89892 }, // ID → whole rupiah, NOT 898.92 + { currency: "USD", amountCents: 999 }, // 2-decimal → $9.99 + ], + ) + + const body = createBodyFrom(fetchMock) + const cfgs: any[] = body.basePlans[0].regionalConfigs + const idr = cfgs.find((c) => c.regionCode === "ID") + // Rp 89,892 sent as whole units (>= Play's IDR 1,000 minimum), NOT Rp 898.92. + expect(idr.price.currencyCode).toBe("IDR") + expect(idr.price.units).toBe("89892") + expect(idr.price.nanos).toBe(0) + // Sanity: a genuine 2-decimal currency still splits into units + nanos. + const usd = cfgs.find((c) => c.regionCode === "US") + expect(usd.price.units).toBe("9") + expect(usd.price.nanos).toBe(990000000) +}) + +test("keeps distinct regions and skips unmapped currencies", async () => { + const fetchMock = mockCreatePath() + + await syncProductToGooglePlay( + { serviceAccountJson: SA_JSON, packageName: "com.example.app" }, + "prod-2", + "pro-annual", + "Pro Annual", + "year", + [ + { currency: "USD", amountCents: 9950 }, // → US + { currency: "INR", amountCents: 799000 }, // → IN + { currency: "XYZ", amountCents: 100 }, // unmapped → skipped + ], + ) + + const body = createBodyFrom(fetchMock) + const regions: string[] = body.basePlans[0].regionalConfigs.map( + (c: any) => c.regionCode, + ) + expect(regions).toEqual(["US", "IN"]) +}) + +test("activates the base plan after create → result.activated = true", async () => { + const fetchMock = mockCreatePath({ activateOk: true }) + + const result = await syncProductToGooglePlay( + { serviceAccountJson: SA_JSON, packageName: "com.example.app" }, + "prod-act", + "pro-monthly", + "Pro Monthly", + "month", + [{ currency: "USD", amountCents: 999 }], + ) + + // 3rd fetch is the activate POST to the correct :activate endpoint. + const [activateUrl, activateInit] = fetchMock.mock.calls[2] + expect(activateUrl).toContain("/subscriptions/pro_monthly/basePlans/pro-monthly-autorenew:activate") + expect(activateInit.method).toBe("POST") + expect(result.activated).toBe(true) + expect(result.activationError).toBeUndefined() +}) + +test("activation is best-effort: an app-not-published 400 does NOT fail the sync", async () => { + const fetchMock = mockCreatePath({ activateOk: false }) + + const result = await syncProductToGooglePlay( + { serviceAccountJson: SA_JSON, packageName: "com.example.app" }, + "prod-act2", + "pro-annual", + "Pro Annual", + "year", + [{ currency: "USD", amountCents: 9950 }], + ) + + // The subscription still synced (id returned), but activation is flagged. + expect(result.playProductId).toBe("pro_annual") + expect(result.created).toBe(true) + expect(result.activated).toBe(false) + expect(result.activationError).toMatch(/not activated/i) + expect(result.activationError).toMatch(/not published/i) + expect(fetchMock).toHaveBeenCalledTimes(3) // get + create + activate (no throw) +}) diff --git a/dashboard/app/(dashboard)/providers/routing/page.tsx b/dashboard/app/(dashboard)/providers/routing/page.tsx index eaa526f..592a95a 100644 --- a/dashboard/app/(dashboard)/providers/routing/page.tsx +++ b/dashboard/app/(dashboard)/providers/routing/page.tsx @@ -61,9 +61,11 @@ export default async function RoutingRulesPage() {

Smart routing

Override the default "cheapest eligible method" picker with - per-(country, currency, product type) priority rules. Each rule is - tried in priority order; first match wins. Leave a field blank to - match everything. + per-(country, currency, product type, platform) priority rules — set a + rule's platform to steer providers per app platform + (e.g. Stripe on desktop, Razorpay on Android), or leave it "Any". Each + rule is tried in priority order; first match wins. Leave a field blank + to match everything.

diff --git a/dashboard/app/api/products/[id]/sync/route.ts b/dashboard/app/api/products/[id]/sync/route.ts index cb6258e..4463c05 100644 --- a/dashboard/app/api/products/[id]/sync/route.ts +++ b/dashboard/app/api/products/[id]/sync/route.ts @@ -137,7 +137,7 @@ export async function POST( // a subscription. "ok" = the product id landed on the row. const googlePlayReport: Report = { status: "skipped" } try { - await googlePlaySyncProduct(supabase, { + const res = await googlePlaySyncProduct(supabase, { tenantId: tenant.id, productId: params.id, body, @@ -153,10 +153,13 @@ export async function POST( googlePlayReport.message = "native store sync only applies to subscription products" } else if (after?.play_product_id) { googlePlayReport.status = "ok" + // Synced, but the base plan may still be DRAFT until the app is published. + if (res.warning) googlePlayReport.message = res.warning } else { googlePlayReport.status = "failed" googlePlayReport.message = - "sync helper returned without populating play_product_id — check that google_play credentials + package_name are configured (server logs carry the Play API error)" + res.error ?? + "sync helper returned without populating play_product_id — check that google_play credentials + package_name are configured" } } catch (e: any) { googlePlayReport.status = "failed" @@ -165,7 +168,7 @@ export async function POST( const appStoreReport: Report = { status: "skipped" } try { - await appStoreSyncProduct(supabase, { + const res = await appStoreSyncProduct(supabase, { tenantId: tenant.id, productId: params.id, body, @@ -184,7 +187,8 @@ export async function POST( } else { appStoreReport.status = "failed" appStoreReport.message = - "sync helper returned without populating app_store_product_id — check that app_store credentials (key_id/issuer_id/bundle_id + .p8) are configured (server logs carry the ASC API error)" + res.error ?? + "sync helper returned without populating app_store_product_id — check that app_store credentials (key_id/issuer_id/bundle_id + .p8) are configured" } } catch (e: any) { appStoreReport.status = "failed" diff --git a/dashboard/app/api/products/sync-to-providers/route.ts b/dashboard/app/api/products/sync-to-providers/route.ts index cb31101..8075272 100644 --- a/dashboard/app/api/products/sync-to-providers/route.ts +++ b/dashboard/app/api/products/sync-to-providers/route.ts @@ -359,7 +359,7 @@ export async function POST() { continue } try { - await googlePlaySyncProduct(supabase, { + const res = await googlePlaySyncProduct(supabase, { tenantId: tenant.id, productId: row.id, body, @@ -376,6 +376,9 @@ export async function POST() { sku: row.sku, display_name: row.display_name, status: "ok", + // Synced, but the base plan may still be DRAFT (activation blocked + // until the app is published) — carry that note even on success. + message: res.warning, }) } else { googlePlayReports.push({ @@ -384,7 +387,8 @@ export async function POST() { display_name: row.display_name, status: "failed", message: - "sync helper returned without populating play_product_id — check that google_play credentials + package_name are configured (server logs carry the Play API error)", + res.error ?? + "sync helper returned without populating play_product_id — check that google_play credentials + package_name are configured", }) } } catch (e: any) { @@ -412,7 +416,7 @@ export async function POST() { continue } try { - await appStoreSyncProduct(supabase, { + const res = await appStoreSyncProduct(supabase, { tenantId: tenant.id, productId: row.id, body, @@ -437,7 +441,8 @@ export async function POST() { display_name: row.display_name, status: "failed", message: - "sync helper returned without populating app_store_product_id — check that app_store credentials (key_id/issuer_id/bundle_id + .p8) are configured (server logs carry the ASC API error)", + res.error ?? + "sync helper returned without populating app_store_product_id — check that app_store credentials (key_id/issuer_id/bundle_id + .p8) are configured", }) } } catch (e: any) { diff --git a/dashboard/app/api/routing-rules/route.ts b/dashboard/app/api/routing-rules/route.ts index b39dc8c..d78b664 100644 --- a/dashboard/app/api/routing-rules/route.ts +++ b/dashboard/app/api/routing-rules/route.ts @@ -34,6 +34,7 @@ export async function POST(req: NextRequest) { const country_code = (body?.country_code ?? "").toString().trim() || null const currency = (body?.currency ?? "").toString().trim() || null const product_type = (body?.product_type ?? "").toString().trim() || null + const platform = ((body?.platform ?? "any").toString().trim().toLowerCase()) || "any" const priority_methods = Array.isArray(body?.priority_methods) ? body.priority_methods.filter((m: any) => typeof m === "string") : [] @@ -63,6 +64,12 @@ export async function POST(req: NextRequest) { { status: 400 }, ) } + if (!["ios", "android", "desktop", "web", "any"].includes(platform)) { + return NextResponse.json( + { error: "platform must be ios / android / desktop / web / any" }, + { status: 400 }, + ) + } const { data: id, error } = await supabase.rpc("tenant_routing_rules_upsert", { p_tenant_id: tenant.id, @@ -71,6 +78,7 @@ export async function POST(req: NextRequest) { p_product_type: product_type ?? null, p_priority_methods: priority_methods, p_priority: priority, + p_platform: platform, }) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) @@ -80,7 +88,7 @@ export async function POST(req: NextRequest) { p_actor_type: "user", p_action: "routing_rule.created", p_resource: `tenant_routing_rules:id=${id}`, - p_after: { country_code, currency, product_type, priority_methods, priority }, + p_after: { country_code, currency, product_type, platform, priority_methods, priority }, }) return NextResponse.json({ id, ok: true }) diff --git a/dashboard/app/auth/callback/route.ts b/dashboard/app/auth/callback/route.ts index d5a65bf..7f23afd 100644 --- a/dashboard/app/auth/callback/route.ts +++ b/dashboard/app/auth/callback/route.ts @@ -46,10 +46,15 @@ export async function GET(request: NextRequest) { // (the previous default — /subscribers). const { data: { user } } = await supabase.auth.getUser() if (user) { + // Does the user admin AT LEAST ONE tenant? `.limit(1).maybeSingle()` returns the first + // membership (or null) and — unlike a bare `.maybeSingle()` — does NOT error when the user + // owns multiple tenants. The bare form threw for any ≥2-tenant owner, making `existing` null + // and wrongly bouncing returning multi-tenant admins to /onboarding on every login. const { data: existing } = await supabase .from("tenant_admins") .select("tenant_id") .eq("user_id", user.id) + .limit(1) .maybeSingle() if (!existing) { diff --git a/dashboard/components/providers/routing-rules-editor.tsx b/dashboard/components/providers/routing-rules-editor.tsx index ee27480..77212b0 100644 --- a/dashboard/components/providers/routing-rules-editor.tsx +++ b/dashboard/components/providers/routing-rules-editor.tsx @@ -29,6 +29,7 @@ interface Rule { country_code: string | null currency: string | null product_type: string | null + platform: string | null priority_methods: string[] priority: number } @@ -149,6 +150,7 @@ function RulesList({ Country Currency Product type + Platform Method order — @@ -166,6 +168,11 @@ function RulesList({ {r.product_type ?? Any} + + {r.platform && r.platform !== "any" + ? r.platform + : Any} +
{r.priority_methods.map((m, i) => { @@ -211,6 +218,7 @@ function NewRuleForm({ const [country, setCountry] = useState("") const [currency, setCurrency] = useState("") const [productType, setProductType] = useState("") + const [platform, setPlatform] = useState("") const [priorityMethods, setPriorityMethods] = useState([]) const [priority, setPriority] = useState(100) const [saving, setSaving] = useState(false) @@ -255,6 +263,7 @@ function NewRuleForm({ country_code: country || null, currency: currency || null, product_type: productType || null, + platform: platform || "any", priority_methods: priorityMethods, priority, }), @@ -269,6 +278,7 @@ function NewRuleForm({ country_code: country || null, currency: currency || null, product_type: productType || null, + platform: platform || "any", priority_methods: priorityMethods, priority, }) @@ -311,7 +321,7 @@ function NewRuleForm({
-
+
+