Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b5d86f6
fix(dashboard): surface Google Play + App Store on the Providers list…
therajanmaurya Jul 24, 2026
e7438ae
fix(dashboard): surface store sync in products UI (bulk + badges + Re…
therajanmaurya Jul 24, 2026
4ecf6c1
chore(source): update .github/workflows/deploy-cloud.yml dashboard/ap…
therajanmaurya Jul 25, 2026
59a92f0
chore(source): update dashboard/__tests__/lib/googleplay-product-sync…
therajanmaurya Jul 25, 2026
590170f
Merge pull request #125 from therajanmaurya/development
mobilebytesenseicommunity Jul 25, 2026
095369f
chore(source): update cmp-paycraft/src/androidMain/kotlin/com/mobileb…
therajanmaurya Jul 25, 2026
c0def04
chore(release): bump paycraft 2.2.0 → 2.3.0 (commonMain-only native b…
therajanmaurya Jul 25, 2026
8c7073e
feat paywall currency fix native store price and usd fallback (#128)
therajanmaurya Jul 29, 2026
11064d5
chore: initialize session branch — chore(paycraft): reconcile + close…
therajanmaurya Jul 31, 2026
e3c8b1f
chore(source): update .gitignore CHANGELOG.md cmp-paycraft/src/common…
therajanmaurya Aug 1, 2026
e1b59a8
chore(source): update cmp-paycraft/src/commonMain/kotlin/com/mobileby…
therajanmaurya Aug 2, 2026
9098577
Merge pull request #129 from therajanmaurya/session-PayCraft-20260731…
mobilebytesenseicommunity Aug 2, 2026
b26abce
chore: initialize session branch — fix(auth): multi-tenant login call…
therajanmaurya Aug 2, 2026
fd74340
chore(source): update dashboard/app/auth/callback/route.ts supabase/.…
therajanmaurya Aug 2, 2026
6d1c5cf
Merge pull request #131 from therajanmaurya/session-PayCraft-20260802…
mobilebytesenseicommunity Aug 2, 2026
60dc053
merge main (#123/#124 store-sync) into development — keep dev's super…
therajanmaurya Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 9 additions & 20 deletions .github/workflows/deploy-cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,22 @@ 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:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
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
Expand Down
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions .vercelignore
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mobilebytelabs.paycraft

import android.app.Application
import android.content.Context
import androidx.startup.Initializer

Expand Down Expand Up @@ -37,6 +38,11 @@ import androidx.startup.Initializer
class PayCraftInitializer : Initializer<Unit> {
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<Class<out Initializer<*>>> = emptyList()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Activity>? = 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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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() },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading