From b5d86f601ca8cda676253f9b2945c64d7a05ad19 Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Fri, 24 Jul 2026 12:25:19 +0530 Subject: [PATCH 01/12] fix(dashboard): surface Google Play + App Store on the Providers list (#123) The store-sync credential pages (/providers/google-play, /providers/app-store) shipped in 2.2.0 but were never linked from the Providers index (only web PSPs render there), so they were unreachable. Adds an 'In-app billing (native app stores)' section with Connect/Manage cards + live connection status. --- dashboard/app/(dashboard)/providers/page.tsx | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/dashboard/app/(dashboard)/providers/page.tsx b/dashboard/app/(dashboard)/providers/page.tsx index 7b1c1ed..7f30c39 100644 --- a/dashboard/app/(dashboard)/providers/page.tsx +++ b/dashboard/app/(dashboard)/providers/page.tsx @@ -197,6 +197,29 @@ export default async function ProvidersPage({ )} + {/* Native in-app billing — Google Play / App Store (required for mobile digital goods) */} +
+
+ + +
+
+ {/* Tier 1+2: Recommended for {country} */} {(byTier.primary.length > 0 || byTier.secondary.length > 0) && (
+ +
+
+

{name}

+

{subtitle}

+
+ {connected ? ( + + Connected + + ) : ( + Setup + )} +
+ +

{reason}

+ + + {connected ? "Manage" : "Connect"} + + +
+ + ) +} + function ComingSoonCard({ recommendation, }: { From e7438ae858fdcb289d1444890ec396f7a441ca5b Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Fri, 24 Jul 2026 12:51:30 +0530 Subject: [PATCH 02/12] fix(dashboard): surface store sync in products UI (bulk + badges + Re-sync feedback) (#124) * fix(dashboard): surface Google Play + App Store on the Providers list (#123) The store-sync credential pages (/providers/google-play, /providers/app-store) shipped in 2.2.0 but were never linked from the Providers index (only web PSPs render there), so they were unreachable. Adds an 'In-app billing (native app stores)' section with Connect/Manage cards + live connection status. * fix(dashboard): surface store sync in products UI (bulk + badges + Re-sync) Closes the two store-sync UI gaps from 2.2.0: - Bulk 'sync-to-providers' now includes Google Play + App Store. - Products table shows Play / App Store badges; per-product Re-sync panel + unsynced banner + sync panel surface store connection/results. Matches the /sync route snake_case keys (google_play/app_store). tsc clean. --- .../app/(dashboard)/products/[id]/page.tsx | 2 + dashboard/app/(dashboard)/products/page.tsx | 92 +++++- .../api/products/sync-to-providers/route.ts | 294 ++++++++++++++---- .../products/product-row-actions.tsx | 27 +- .../products/product-sync-panel.tsx | 39 ++- .../products/unsynced-products-banner.tsx | 46 ++- 6 files changed, 430 insertions(+), 70 deletions(-) diff --git a/dashboard/app/(dashboard)/products/[id]/page.tsx b/dashboard/app/(dashboard)/products/[id]/page.tsx index 31a8235..cd95da6 100644 --- a/dashboard/app/(dashboard)/products/[id]/page.tsx +++ b/dashboard/app/(dashboard)/products/[id]/page.tsx @@ -97,6 +97,8 @@ export default async function ProductViewPage({ productId={params.id} initialStripeProductId={p.stripe_product_id ?? null} initialRazorpayPlanIds={p.razorpay_plan_id_by_currency ?? null} + initialPlayProductId={p.play_product_id ?? null} + initialAppStoreProductId={p.app_store_product_id ?? null} stripeVerification={stripeVerification} /> diff --git a/dashboard/app/(dashboard)/products/page.tsx b/dashboard/app/(dashboard)/products/page.tsx index 9569a68..53ad56a 100644 --- a/dashboard/app/(dashboard)/products/page.tsx +++ b/dashboard/app/(dashboard)/products/page.tsx @@ -29,6 +29,8 @@ type Product = { stripe_product_id: string | null stripe_price_id_by_currency: Record | null razorpay_plan_id_by_currency: Record | null + play_product_id: string | null + app_store_product_id: string | null } function formatMoney(cents: number, currency: string): string { @@ -49,7 +51,14 @@ function formatMoney(cents: number, currency: string): string { export default async function ProductsPage() { const { tenant } = await requireTenant() const supabase = createClient() - const [productsRes, mrrRes, stripeStatusRes, razorpayStatusRes] = await Promise.all([ + const [ + productsRes, + mrrRes, + stripeStatusRes, + razorpayStatusRes, + playStatusRes, + appStoreStatusRes, + ] = await Promise.all([ supabase.rpc("tenant_products_list", { p_tenant_id: tenant.id }), supabase .from("tenant_revenue_by_plan_view") @@ -67,12 +76,29 @@ export default async function ProductsPage() { p_provider: "razorpay", }) .single<{ connected: boolean }>(), + // Native store connection probes — the store-credential twin of + // tenant_providers_status (migration 074). Drives the PLAY / APP STORE + // chip gray-vs-amber-vs-green states, same as the web PSPs above. + supabase + .rpc("tenant_providers_store_status", { + p_tenant_id: tenant.id, + p_provider: "google_play", + }) + .single<{ connected: boolean }>(), + supabase + .rpc("tenant_providers_store_status", { + p_tenant_id: tenant.id, + p_provider: "app_store", + }) + .single<{ connected: boolean }>(), ]) const rows = (productsRes.data as Product[] | null) ?? [] const stripeConnected = !!stripeStatusRes.data?.source const stripeLivemode = !!stripeStatusRes.data?.livemode const stripeAccountHint = stripeStatusRes.data?.account_id ?? null const razorpayConnected = !!razorpayStatusRes.data?.connected + const playConnected = !!playStatusRes.data?.connected + const appStoreConnected = !!appStoreStatusRes.data?.connected // Verify each row's stripe_product_id actually lives on the current // connected account — DB-only "is it null?" check is unreliable after key @@ -261,6 +287,18 @@ export default async function ProductsPage() { Object.keys(r.razorpay_plan_id_by_currency).length > 0 } /> + + @@ -289,6 +327,10 @@ export default async function ProductsPage() { stripeLivemode={stripeLivemode} stripeConnected={stripeConnected} razorpayConnected={razorpayConnected} + hasPlay={!!r.play_product_id} + hasAppStore={!!r.app_store_product_id} + playConnected={playConnected} + appStoreConnected={appStoreConnected} /> @@ -463,3 +505,51 @@ function RazorpayChip({ ) } + +/** + * Native store chip (Google Play / App Store) — same 3-state treatment as + * RazorpayChip (the stores don't expose a browser deep-link for a product the + * way Stripe does, so there's no verified-with-external-link variant): + * + * not-connected — gray pill linking to the store's credential form + * pending — amber pill (connected but play/app_store_product_id null) + * synced — green "✓" pill (the store product id is populated) + */ +function StoreChip({ + name, + connected, + synced, + settingsUrl, +}: { + name: string + connected: boolean + synced: boolean + settingsUrl: string +}) { + if (!connected) { + return ( + + {name} + + ) + } + if (!synced) { + return ( + + {name} · pending + + ) + } + return ( + + {name} ✓ + + ) +} diff --git a/dashboard/app/api/products/sync-to-providers/route.ts b/dashboard/app/api/products/sync-to-providers/route.ts index 5e0914d..cb31101 100644 --- a/dashboard/app/api/products/sync-to-providers/route.ts +++ b/dashboard/app/api/products/sync-to-providers/route.ts @@ -1,46 +1,97 @@ import { NextResponse } from "next/server" import { createClient } from "@/lib/supabase-server" import { requireTenant } from "@/lib/tenant" -import { stripeSyncProduct, razorpaySyncProduct } from "@/lib/stripe-route-helper" +import { + stripeSyncProduct, + razorpaySyncProduct, + googlePlaySyncProduct, + appStoreSyncProduct, +} from "@/lib/stripe-route-helper" /** - * Bulk re-sync of locally-saved products to the connected payment providers. + * Bulk re-sync of locally-saved products to the connected providers. * - * Use case: the operator created tenant_products rows BEFORE connecting Stripe - * / Razorpay (or under a provider that was later replaced), so those rows - * lack stripe_product_id / razorpay_plan_id_by_currency. Once a provider is - * connected, this route pushes every "unsynced" product up to the provider's - * API in sequence (idempotency keys in stripe-product-sync.ts make this safe - * to retry). + * Use case: the operator created tenant_products rows BEFORE connecting a + * provider (Stripe / Razorpay for web PSPs, or Google Play / App Store for the + * native billing lanes), so those rows lack stripe_product_id / + * razorpay_plan_id_by_currency / play_product_id / app_store_product_id. Once a + * provider is connected, this route pushes every "unsynced" product up to that + * provider's API in sequence (idempotency keys in the *-product-sync helpers + * make this safe to retry). * * GET — preview: returns counts of unsynced products per provider. - * POST — execute: iterates the unsynced sets, runs stripe/razorpaySyncProduct - * for each, returns a per-product result array. + * POST — execute: iterates the unsynced sets, runs the matching *SyncProduct + * helper for each, returns a per-product result array per provider. */ /** - * Whether a given payment provider is actually connected for this tenant. We - * only nag the operator about unsynced products for providers they've wired - * up — otherwise the banner would permanently complain about Razorpay drift - * on a Stripe-only deployment, etc. + * Which providers are actually connected for this tenant. We only nag the + * operator about unsynced products for providers they've wired up — otherwise + * the banner would permanently complain about Razorpay / App Store drift on a + * Stripe-only deployment, etc. Native stores are probed via + * tenant_providers_store_status (the store-credential twin of + * tenant_providers_status). */ async function providerConnections( supabase: ReturnType, tenantId: string, -): Promise<{ stripe: boolean; razorpay: boolean }> { - const [stripeStatus, razorpayStatus] = await Promise.all([ - supabase - .rpc("tenant_stripe_provider_status", { p_tenant_id: tenantId }) - .single<{ source: string | null }>(), - supabase - .rpc("tenant_providers_status", { p_tenant_id: tenantId, p_provider: "razorpay" }) - .single<{ connected: boolean }>(), - ]) +): Promise<{ + stripe: boolean + razorpay: boolean + google_play: boolean + app_store: boolean +}> { + const [stripeStatus, razorpayStatus, playStatus, appStoreStatus] = + await Promise.all([ + supabase + .rpc("tenant_stripe_provider_status", { p_tenant_id: tenantId }) + .single<{ source: string | null }>(), + supabase + .rpc("tenant_providers_status", { p_tenant_id: tenantId, p_provider: "razorpay" }) + .single<{ connected: boolean }>(), + supabase + .rpc("tenant_providers_store_status", { p_tenant_id: tenantId, p_provider: "google_play" }) + .single<{ connected: boolean }>(), + supabase + .rpc("tenant_providers_store_status", { p_tenant_id: tenantId, p_provider: "app_store" }) + .single<{ connected: boolean }>(), + ]) return { stripe: !!stripeStatus.data?.source, razorpay: !!razorpayStatus.data?.connected, + google_play: !!playStatus.data?.connected, + app_store: !!appStoreStatus.data?.connected, } } +/** + * Products still missing their native-store product id. There's no + * tenant_products_unsynced branch for the stores (that RPC only knows + * stripe/razorpay), so we probe tenant_products directly. Native stores only + * apply to subscription products (the sync helpers self-skip non-subscriptions), + * so we filter to type = 'subscription' + active here to mirror that contract + * and keep the preview counts honest. Row shape matches the + * tenant_products_unsynced RPC (id / sku / display_name) so downstream + * consumers can treat every provider's items[] uniformly. + */ +async function storeUnsyncedRows( + supabase: ReturnType, + tenantId: string, + provider: "google_play" | "app_store", +): Promise { + const column = + provider === "google_play" ? "play_product_id" : "app_store_product_id" + const { data } = await supabase + .from("tenant_products") + .select("id, sku, display_name, type, interval, base_price_cents, base_currency") + .eq("tenant_id", tenantId) + .eq("active", true) + .eq("type", "subscription") + .is(column, null) + .order("display_order") + .order("created_at") + return data ?? [] +} + export async function GET() { const { tenant } = await requireTenant() const supabase = createClient() @@ -49,28 +100,39 @@ export async function GET() { // Only probe unsynced products for connected providers — we don't want to // surface "4 not synced to Razorpay" when Razorpay isn't even configured. - const [stripeRowsResp, razorpayRowsResp] = await Promise.all([ - connected.stripe - ? supabase.rpc("tenant_products_unsynced", { - p_tenant_id: tenant.id, - p_provider: "stripe", - }) - : Promise.resolve({ data: [] }), - connected.razorpay - ? supabase.rpc("tenant_products_unsynced", { - p_tenant_id: tenant.id, - p_provider: "razorpay", - }) - : Promise.resolve({ data: [] }), - ]) + const [stripeRowsResp, razorpayRowsResp, playRowsResp, appStoreRowsResp] = + await Promise.all([ + connected.stripe + ? supabase.rpc("tenant_products_unsynced", { + p_tenant_id: tenant.id, + p_provider: "stripe", + }) + : Promise.resolve({ data: [] }), + connected.razorpay + ? supabase.rpc("tenant_products_unsynced", { + p_tenant_id: tenant.id, + p_provider: "razorpay", + }) + : Promise.resolve({ data: [] }), + connected.google_play + ? storeUnsyncedRows(supabase, tenant.id, "google_play") + : Promise.resolve([] as any[]), + connected.app_store + ? storeUnsyncedRows(supabase, tenant.id, "app_store") + : Promise.resolve([] as any[]), + ]) const stripeRows = stripeRowsResp.data ?? [] const razorpayRows = razorpayRowsResp.data ?? [] + const playRows = playRowsResp ?? [] + const appStoreRows = appStoreRowsResp ?? [] - // Distinct-product count — the same row showing up in both lists shouldn't + // Distinct-product count — the same row showing up in several lists shouldn't // be counted twice (banner shows "N products need sync", not "N sync ops"). const uniqueIds = new Set([ ...stripeRows.map((r: any) => r.id), ...razorpayRows.map((r: any) => r.id), + ...playRows.map((r: any) => r.id), + ...appStoreRows.map((r: any) => r.id), ]) return NextResponse.json({ @@ -78,6 +140,8 @@ export async function GET() { unique_unsynced_count: uniqueIds.size, stripe: { unsynced_count: stripeRows.length, items: stripeRows }, razorpay: { unsynced_count: razorpayRows.length, items: razorpayRows }, + google_play: { unsynced_count: playRows.length, items: playRows }, + app_store: { unsynced_count: appStoreRows.length, items: appStoreRows }, }) } @@ -100,7 +164,7 @@ async function loadFullProductBodies( const { data: products = [] } = await supabase .from("tenant_products") .select( - "id, sku, type, display_name, interval, base_price_cents, base_currency, stripe_product_id, stripe_price_id_by_currency, razorpay_plan_id_by_currency", + "id, sku, type, display_name, interval, base_price_cents, base_currency, stripe_product_id, stripe_price_id_by_currency, razorpay_plan_id_by_currency, play_product_id, app_store_product_id", ) .eq("tenant_id", tenantId) .in("id", ids) @@ -133,26 +197,37 @@ export async function POST() { const connected = await providerConnections(supabase, tenant.id) - const [stripeRowsResp, razorpayRowsResp] = await Promise.all([ - connected.stripe - ? supabase.rpc("tenant_products_unsynced", { - p_tenant_id: tenant.id, - p_provider: "stripe", - }) - : Promise.resolve({ data: [] }), - connected.razorpay - ? supabase.rpc("tenant_products_unsynced", { - p_tenant_id: tenant.id, - p_provider: "razorpay", - }) - : Promise.resolve({ data: [] }), - ]) + const [stripeRowsResp, razorpayRowsResp, playRowsResp, appStoreRowsResp] = + await Promise.all([ + connected.stripe + ? supabase.rpc("tenant_products_unsynced", { + p_tenant_id: tenant.id, + p_provider: "stripe", + }) + : Promise.resolve({ data: [] }), + connected.razorpay + ? supabase.rpc("tenant_products_unsynced", { + p_tenant_id: tenant.id, + p_provider: "razorpay", + }) + : Promise.resolve({ data: [] }), + connected.google_play + ? storeUnsyncedRows(supabase, tenant.id, "google_play") + : Promise.resolve([] as any[]), + connected.app_store + ? storeUnsyncedRows(supabase, tenant.id, "app_store") + : Promise.resolve([] as any[]), + ]) const stripeRows = stripeRowsResp.data ?? [] const razorpayRows = razorpayRowsResp.data ?? [] + const playRows = playRowsResp ?? [] + const appStoreRows = appStoreRowsResp ?? [] const allIds = [ ...((stripeRows ?? []) as any[]).map((r) => r.id), ...((razorpayRows ?? []) as any[]).map((r) => r.id), + ...((playRows ?? []) as any[]).map((r) => r.id), + ...((appStoreRows ?? []) as any[]).map((r) => r.id), ] const bodies = await loadFullProductBodies( supabase, @@ -267,6 +342,115 @@ export async function POST() { } } + // Native stores — the helpers self-skip non-subscription products + tenants + // that haven't stored store credentials, and write play_product_id / + // app_store_product_id back on success. "ok" = the id landed on the row. + const googlePlayReports: SyncReport[] = [] + for (const row of (playRows ?? []) as any[]) { + const body = bodies[row.id] + if (!body) { + googlePlayReports.push({ + product_id: row.id, + sku: row.sku, + display_name: row.display_name, + status: "skipped", + message: "product row not found during hydration", + }) + continue + } + try { + await googlePlaySyncProduct(supabase, { + tenantId: tenant.id, + productId: row.id, + body, + existingPlayProductId: body.play_product_id ?? undefined, + }) + const { data: after } = await supabase + .from("tenant_products") + .select("play_product_id") + .eq("id", row.id) + .single() + if (after?.play_product_id) { + googlePlayReports.push({ + product_id: row.id, + sku: row.sku, + display_name: row.display_name, + status: "ok", + }) + } else { + googlePlayReports.push({ + product_id: row.id, + sku: row.sku, + 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)", + }) + } + } catch (e: any) { + googlePlayReports.push({ + product_id: row.id, + sku: row.sku, + display_name: row.display_name, + status: "failed", + message: e?.message ?? String(e), + }) + } + } + + const appStoreReports: SyncReport[] = [] + for (const row of (appStoreRows ?? []) as any[]) { + const body = bodies[row.id] + if (!body) { + appStoreReports.push({ + product_id: row.id, + sku: row.sku, + display_name: row.display_name, + status: "skipped", + message: "product row not found during hydration", + }) + continue + } + try { + await appStoreSyncProduct(supabase, { + tenantId: tenant.id, + productId: row.id, + body, + existingAppStoreProductId: body.app_store_product_id ?? undefined, + }) + const { data: after } = await supabase + .from("tenant_products") + .select("app_store_product_id") + .eq("id", row.id) + .single() + if (after?.app_store_product_id) { + appStoreReports.push({ + product_id: row.id, + sku: row.sku, + display_name: row.display_name, + status: "ok", + }) + } else { + appStoreReports.push({ + product_id: row.id, + sku: row.sku, + 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)", + }) + } + } catch (e: any) { + appStoreReports.push({ + product_id: row.id, + sku: row.sku, + display_name: row.display_name, + status: "failed", + message: e?.message ?? String(e), + }) + } + } + await supabase.rpc("audit_log_emit", { p_tenant_id: tenant.id, p_actor_user_id: userId, @@ -276,11 +460,15 @@ export async function POST() { p_after: { stripe: stripeReports.map((r) => ({ id: r.product_id, status: r.status })), razorpay: razorpayReports.map((r) => ({ id: r.product_id, status: r.status })), + google_play: googlePlayReports.map((r) => ({ id: r.product_id, status: r.status })), + app_store: appStoreReports.map((r) => ({ id: r.product_id, status: r.status })), }, }) return NextResponse.json({ stripe: stripeReports, razorpay: razorpayReports, + google_play: googlePlayReports, + app_store: appStoreReports, }) } diff --git a/dashboard/components/products/product-row-actions.tsx b/dashboard/components/products/product-row-actions.tsx index 9501e14..be3195d 100644 --- a/dashboard/components/products/product-row-actions.tsx +++ b/dashboard/components/products/product-row-actions.tsx @@ -39,6 +39,10 @@ export function ProductRowActions({ stripeLivemode, stripeConnected, razorpayConnected, + hasPlay, + hasAppStore, + playConnected, + appStoreConnected, }: { productId: string sku: string @@ -47,13 +51,22 @@ export function ProductRowActions({ stripeLivemode: boolean stripeConnected: boolean razorpayConnected: boolean + hasPlay: boolean + hasAppStore: boolean + playConnected: boolean + appStoreConnected: boolean }) { const router = useRouter() const [open, setOpen] = useState(false) const [syncing, setSyncing] = useState(false) + // Report shape mirrors the /api/products/{id}/sync response keys exactly: + // stripe / razorpay / cashfree / google_play / app_store (snake_case). const [lastResult, setLastResult] = useState<{ stripe: SyncReport razorpay: SyncReport + cashfree?: SyncReport + google_play?: SyncReport + app_store?: SyncReport } | null>(null) const [error, setError] = useState(null) @@ -62,7 +75,10 @@ export function ProductRowActions({ ? `https://dashboard.stripe.com/${stripeLivemode ? "" : "test/"}products/${stripeProductId}` : null - const canSync = stripeConnected || razorpayConnected + // Re-sync is enabled when ANY provider — web PSP or native store — is + // connected, so a store-only tenant can still push subscription products. + const canSync = + stripeConnected || razorpayConnected || playConnected || appStoreConnected async function reSync() { setSyncing(true) @@ -162,6 +178,15 @@ export function ProductRowActions({
+ {lastResult.cashfree && ( + + )} + {lastResult.google_play && ( + + )} + {lastResult.app_store && ( + + )}
)} {error && ( diff --git a/dashboard/components/products/product-sync-panel.tsx b/dashboard/components/products/product-sync-panel.tsx index 30edd80..a62a955 100644 --- a/dashboard/components/products/product-sync-panel.tsx +++ b/dashboard/components/products/product-sync-panel.tsx @@ -21,11 +21,18 @@ interface Props { productId: string initialStripeProductId: string | null initialRazorpayPlanIds: Record | null + initialPlayProductId: string | null + initialAppStoreProductId: string | null stripeVerification: StripeVerification } interface ProviderConnections { - providers_connected: { stripe: boolean; razorpay: boolean } + providers_connected: { + stripe: boolean + razorpay: boolean + google_play: boolean + app_store: boolean + } } /** @@ -39,14 +46,21 @@ export function ProductSyncPanel({ productId, initialStripeProductId, initialRazorpayPlanIds, + initialPlayProductId, + initialAppStoreProductId, stripeVerification, }: Props) { const router = useRouter() const [connections, setConnections] = useState(null) const [syncing, setSyncing] = useState(false) + // Report keys mirror the /api/products/{id}/sync response exactly: + // stripe / razorpay / cashfree / google_play / app_store (snake_case). const [result, setResult] = useState<{ stripe: SyncReport razorpay: SyncReport + cashfree?: SyncReport + google_play?: SyncReport + app_store?: SyncReport } | null>(null) const [error, setError] = useState(null) @@ -94,6 +108,8 @@ export function ProductSyncPanel({ : null const razorpaySynced = !!initialRazorpayPlanIds && Object.keys(initialRazorpayPlanIds).length > 0 + const playSynced = !!initialPlayProductId + const appStoreSynced = !!initialAppStoreProductId return (
@@ -144,12 +160,33 @@ export function ProductSyncPanel({ externalUrl={null} settingsUrl="/providers/razorpay" /> + +
{result && (
+ {result.cashfree && } + {result.google_play && ( + + )} + {result.app_store && ( + + )}
)} diff --git a/dashboard/components/products/unsynced-products-banner.tsx b/dashboard/components/products/unsynced-products-banner.tsx index 7a4abcf..029bf46 100644 --- a/dashboard/components/products/unsynced-products-banner.tsx +++ b/dashboard/components/products/unsynced-products-banner.tsx @@ -10,10 +10,17 @@ import { } from "lucide-react" interface Preview { - providers_connected: { stripe: boolean; razorpay: boolean } + providers_connected: { + stripe: boolean + razorpay: boolean + google_play: boolean + app_store: boolean + } unique_unsynced_count: number stripe: { unsynced_count: number; items: any[] } razorpay: { unsynced_count: number; items: any[] } + google_play: { unsynced_count: number; items: any[] } + app_store: { unsynced_count: number; items: any[] } } interface SyncReport { @@ -27,6 +34,8 @@ interface SyncReport { interface SyncResult { stripe: SyncReport[] razorpay: SyncReport[] + google_play?: SyncReport[] + app_store?: SyncReport[] } /** @@ -90,6 +99,8 @@ export function UnsyncedProductsBanner() { const stripeCount = preview.stripe.unsynced_count const razorpayCount = preview.razorpay.unsynced_count + const playCount = preview.google_play.unsynced_count + const appStoreCount = preview.app_store.unsynced_count // Distinct-product count — the same row may need pushing to both providers // but the banner counts unique products, not sync operations. The server // also only counts connected providers (no "needs sync to Razorpay" nag @@ -102,17 +113,21 @@ export function UnsyncedProductsBanner() { const connectedProviderNames = [ preview.providers_connected.stripe ? "Stripe" : null, preview.providers_connected.razorpay ? "Razorpay" : null, + preview.providers_connected.google_play ? "Google Play" : null, + preview.providers_connected.app_store ? "App Store" : null, ].filter(Boolean) as string[] // Post-sync summary takes over (the unsynced count drops to 0 after a // successful run, so we wouldn't otherwise show anything). if (result) { - const allOk = - result.stripe.every((r) => r.status === "ok") && - result.razorpay.every((r) => r.status === "ok") - const failures = [...result.stripe, ...result.razorpay].filter( - (r) => r.status !== "ok", - ) + const allReports = [ + ...result.stripe, + ...result.razorpay, + ...(result.google_play ?? []), + ...(result.app_store ?? []), + ] + const allOk = allReports.every((r) => r.status === "ok") + const failures = allReports.filter((r) => r.status !== "ok") return (

{allOk - ? `Sync complete — ${ - result.stripe.length + result.razorpay.length - } products pushed to providers` - : `Sync partially complete — ${failures.length} of ${ - result.stripe.length + result.razorpay.length - } failed`} + ? `Sync complete — ${allReports.length} products pushed to providers` + : `Sync partially complete — ${failures.length} of ${allReports.length} failed`}

{!allOk && (
    @@ -189,7 +200,14 @@ export function UnsyncedProductsBanner() {

    {connectedProviderNames.length > 1 && (

    - Stripe: {stripeCount} · Razorpay: {razorpayCount} + {[ + preview.providers_connected.stripe ? `Stripe: ${stripeCount}` : null, + preview.providers_connected.razorpay ? `Razorpay: ${razorpayCount}` : null, + preview.providers_connected.google_play ? `Google Play: ${playCount}` : null, + preview.providers_connected.app_store ? `App Store: ${appStoreCount}` : null, + ] + .filter(Boolean) + .join(" · ")}

    )}
    From 4ecf6c1d6464286810f569954338d435d3e662da Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Sat, 25 Jul 2026 13:31:52 +0530 Subject: [PATCH 03/12] chore(source): update .github/workflows/deploy-cloud.yml dashboard/app/api/products/[id]/sync/route.ts dashboard/app/api/products/sync-to-providers/route.ts --- .github/workflows/deploy-cloud.yml | 29 ++-- .vercelignore | 11 ++ .../lib/googleplay-product-sync.test.ts | 150 ++++++++++++++++++ dashboard/app/api/products/[id]/sync/route.ts | 10 +- .../api/products/sync-to-providers/route.ts | 10 +- dashboard/lib/googleplay-product-sync.ts | 41 ++++- dashboard/lib/stripe-route-helper.ts | 30 ++-- supabase/.temp/cli-latest | 2 +- 8 files changed, 234 insertions(+), 49 deletions(-) create mode 100644 .vercelignore create mode 100644 dashboard/__tests__/lib/googleplay-product-sync.test.ts 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/.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/dashboard/__tests__/lib/googleplay-product-sync.test.ts b/dashboard/__tests__/lib/googleplay-product-sync.test.ts new file mode 100644 index 0000000..3c8b352 --- /dev/null +++ b/dashboard/__tests__/lib/googleplay-product-sync.test.ts @@ -0,0 +1,150 @@ +/** + * 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() { + 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 () => "{}" }) + ;(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"]) +}) diff --git a/dashboard/app/api/products/[id]/sync/route.ts b/dashboard/app/api/products/[id]/sync/route.ts index cb6258e..393e8a5 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, @@ -156,7 +156,8 @@ export async function POST( } 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 +166,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 +185,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..71e9d14 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, @@ -384,7 +384,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 +413,7 @@ export async function POST() { continue } try { - await appStoreSyncProduct(supabase, { + const res = await appStoreSyncProduct(supabase, { tenantId: tenant.id, productId: row.id, body, @@ -437,7 +438,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/lib/googleplay-product-sync.ts b/dashboard/lib/googleplay-product-sync.ts index d1b57c5..20c2a89 100644 --- a/dashboard/lib/googleplay-product-sync.ts +++ b/dashboard/lib/googleplay-product-sync.ts @@ -1,4 +1,12 @@ import { playAccessToken, type PlayServiceAccountJson } from "./store-jwt" +// SINGLE SOURCE OF TRUTH for minor-unit semantics: prices in tenant_pricing are +// generated/stored by pricing-template.ts using THIS set to decide whether an +// amount is whole-units (zero-decimal, e.g. IDR/COP/JPY/VND) or ×100 minor +// units. Any consumer that reads amount_cents back MUST use the same set, or it +// mis-scales the price — a divergent local copy (the old Stripe-style list here +// omitted IDR + COP) is exactly what sent Play "IDR 898.92" for a Rp 89,892 +// price and got rejected as below the IDR minimum. +import { ZERO_DECIMAL_CURRENCIES } from "./pricing-template-data" /** * Create / update a Google Play subscription (+ auto-renewing base plan) for a @@ -46,11 +54,6 @@ export interface GooglePlaySyncResult { created: boolean } -const ZERO_DECIMAL_CURRENCIES = new Set([ - "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", - "UGX", "VND", "VUV", "XAF", "XOF", "XPF", -]) - // Minimal ISO-4217 currency → CLDR region map for the common PayCraft set. // Unmapped currencies are skipped (logged) rather than guessed. const CURRENCY_REGION: Record = { @@ -71,11 +74,18 @@ function playBillingPeriod(interval: string | null | undefined): string { } } -/** Play product ids: lowercase, [a-z0-9._-], must start+end alphanumeric, ≤ 40 chars. */ +/** + * Play SUBSCRIPTION product ids: lowercase letters, digits, underscore (_) and + * period (.) only — must start with a letter/number, ≤ 40 chars. Crucially, + * HYPHENS are NOT allowed here (unlike base-plan ids, which do allow them), so a + * SKU like "pro-monthly" must become "pro_monthly" or Play rejects the create + * with 400 "Subscription ID is malformed". Anything outside the allowed set + * (including "-") maps to underscore. + */ function sanitizePlayProductId(sku: string): string { - let id = sku.toLowerCase().replace(/[^a-z0-9._-]/g, ".").replace(/^[._-]+|[._-]+$/g, "") + let id = sku.toLowerCase().replace(/[^a-z0-9._]/g, "_").replace(/^[._]+|[._]+$/g, "") if (!id) id = "product" - return id.slice(0, 40).replace(/[._-]+$/g, "") || "product" + return id.slice(0, 40).replace(/[._]+$/g, "") || "product" } /** Base plan ids: lowercase, [a-z0-9-], ≤ 63 chars. */ @@ -161,7 +171,15 @@ export async function syncProductToGooglePlay( } // 2. Not found → CREATE subscription + one auto-renewing base plan. + // + // Play's base-plan pricing is keyed by REGION, not currency, and the API + // rejects the whole create with 400 "Region code X is duplicated." if the + // same regionCode appears twice. Our currency→region map is many-to-one + // (e.g. every euro-zone price resolves to DE), so a tenant pricing matrix + // that carries two prices landing on the same region MUST be collapsed to a + // single regionalConfig — first price for a region wins, deterministically. const regionalConfigs: Array> = [] + const seenRegions = new Set() for (const { currency, amountCents } of prices) { const region = CURRENCY_REGION[currency.toUpperCase()] if (!region) { @@ -170,6 +188,13 @@ export async function syncProductToGooglePlay( ) continue } + if (seenRegions.has(region)) { + console.warn( + `[googleplay-product-sync] region ${region} already priced (from an earlier currency); skipping duplicate ${currency} price for ${productId}`, + ) + continue + } + seenRegions.add(region) regionalConfigs.push({ regionCode: region, newSubscriberAvailability: true, diff --git a/dashboard/lib/stripe-route-helper.ts b/dashboard/lib/stripe-route-helper.ts index 6a16c3e..4761391 100644 --- a/dashboard/lib/stripe-route-helper.ts +++ b/dashboard/lib/stripe-route-helper.ts @@ -226,24 +226,24 @@ export async function cashfreeSyncProduct( export async function googlePlaySyncProduct( supabase: ReturnType, opts: SyncOptions, -): Promise { +): Promise<{ error?: string }> { const { tenantId, productId, body, existingPlayProductId } = opts try { - if (body.type !== "subscription") return + if (body.type !== "subscription") return {} const { data: status } = await supabase .rpc("tenant_providers_store_status", { p_tenant_id: tenantId, p_provider: "google_play" }) .single<{ connected: boolean; config: Record }>() - if (!status?.connected) return + if (!status?.connected) return { error: "Google Play is not connected for this tenant" } const { data: decrypted } = await supabase .rpc("tenant_providers_decrypt_store_key", { p_tenant_id: tenantId, p_provider: "google_play" }) .single<{ credential: string | null; config: Record }>() - if (!decrypted?.credential) return + if (!decrypted?.credential) return { error: "no stored Google Play service-account credential" } const packageName = decrypted.config?.package_name if (!packageName) { console.error("[products] google play sync skipped: no package_name in tenant store config") - return + return { error: "no package_name in Google Play store config" } } const prices = buildPriceInputs(body).map((p) => ({ @@ -266,8 +266,11 @@ export async function googlePlaySyncProduct( p_play_product_id: result.playProductId, p_app_store_product_id: null, }) + return {} } catch (e: any) { - console.error("[products] google play sync failed:", e?.message ?? String(e)) + const msg = e?.message ?? String(e) + console.error("[products] google play sync failed:", msg) + return { error: msg } } } @@ -280,24 +283,24 @@ export async function googlePlaySyncProduct( export async function appStoreSyncProduct( supabase: ReturnType, opts: SyncOptions, -): Promise { +): Promise<{ error?: string }> { const { tenantId, productId, body, existingAppStoreProductId } = opts try { - if (body.type !== "subscription") return + if (body.type !== "subscription") return {} const { data: status } = await supabase .rpc("tenant_providers_store_status", { p_tenant_id: tenantId, p_provider: "app_store" }) .single<{ connected: boolean; config: Record }>() - if (!status?.connected) return + if (!status?.connected) return { error: "App Store is not connected for this tenant" } const { data: decrypted } = await supabase .rpc("tenant_providers_decrypt_store_key", { p_tenant_id: tenantId, p_provider: "app_store" }) .single<{ credential: string | null; config: Record }>() - if (!decrypted?.credential) return + if (!decrypted?.credential) return { error: "no stored App Store .p8 private key" } const cfg = decrypted.config ?? {} if (!cfg.key_id || !cfg.issuer_id || !cfg.bundle_id) { console.error("[products] app store sync skipped: missing key_id/issuer_id/bundle_id in tenant store config") - return + return { error: "missing key_id/issuer_id/bundle_id in App Store store config" } } const prices = buildPriceInputs(body).map((p) => ({ @@ -325,7 +328,10 @@ export async function appStoreSyncProduct( p_play_product_id: null, p_app_store_product_id: result.appStoreProductId, }) + return {} } catch (e: any) { - console.error("[products] app store sync failed:", e?.message ?? String(e)) + const msg = e?.message ?? String(e) + console.error("[products] app store sync failed:", msg) + return { error: msg } } } diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index 114c98e..8403edf 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.107.0 \ No newline at end of file +v2.109.1 \ No newline at end of file From 59a92f08e4251cdb23487f6cb67c01c136e288c6 Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Sat, 25 Jul 2026 14:25:19 +0530 Subject: [PATCH 04/12] chore(source): update dashboard/__tests__/lib/googleplay-product-sync.test.ts dashboard/app/api/products/[id]/sync/route.ts dashboard/app/api/products/sync-to-providers/route.ts --- .../lib/googleplay-product-sync.test.ts | 55 +++++++++++++- dashboard/app/api/products/[id]/sync/route.ts | 2 + .../api/products/sync-to-providers/route.ts | 3 + dashboard/lib/googleplay-product-sync.ts | 74 ++++++++++++++++++- dashboard/lib/stripe-route-helper.ts | 13 +++- 5 files changed, 143 insertions(+), 4 deletions(-) diff --git a/dashboard/__tests__/lib/googleplay-product-sync.test.ts b/dashboard/__tests__/lib/googleplay-product-sync.test.ts index 3c8b352..d5d7179 100644 --- a/dashboard/__tests__/lib/googleplay-product-sync.test.ts +++ b/dashboard/__tests__/lib/googleplay-product-sync.test.ts @@ -27,13 +27,25 @@ const SA_JSON = JSON.stringify({ }) /** GET probe → 404 (absent), POST create → 200. Returns the fetch mock. */ -function mockCreatePath() { +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 } @@ -148,3 +160,44 @@ test("keeps distinct regions and skips unmapped currencies", async () => { ) 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/api/products/[id]/sync/route.ts b/dashboard/app/api/products/[id]/sync/route.ts index 393e8a5..4463c05 100644 --- a/dashboard/app/api/products/[id]/sync/route.ts +++ b/dashboard/app/api/products/[id]/sync/route.ts @@ -153,6 +153,8 @@ 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 = diff --git a/dashboard/app/api/products/sync-to-providers/route.ts b/dashboard/app/api/products/sync-to-providers/route.ts index 71e9d14..8075272 100644 --- a/dashboard/app/api/products/sync-to-providers/route.ts +++ b/dashboard/app/api/products/sync-to-providers/route.ts @@ -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({ diff --git a/dashboard/lib/googleplay-product-sync.ts b/dashboard/lib/googleplay-product-sync.ts index 20c2a89..a89049e 100644 --- a/dashboard/lib/googleplay-product-sync.ts +++ b/dashboard/lib/googleplay-product-sync.ts @@ -52,6 +52,14 @@ export interface GooglePlaySyncResult { playProductId: string basePlanId: string created: boolean + /** + * Whether the base plan is ACTIVE (purchasable) after this sync. A freshly + * created base plan is DRAFT until activated, and Play only allows activation + * once the app is published — so this can be false even on a successful sync. + */ + activated: boolean + /** Human-readable reason the base plan is not active (present iff !activated). */ + activationError?: string } // Minimal ISO-4217 currency → CLDR region map for the common PayCraft set. @@ -119,6 +127,49 @@ async function playFetch( }) } +/** Pull Google's `error.message` out of an API error body, else a short slice. */ +function shortPlayError(body: string): string { + try { + return (JSON.parse(body)?.error?.message as string) || body.slice(0, 200) + } catch { + return body.slice(0, 200) + } +} + +/** + * Best-effort activation of a base plan so the subscription is actually + * PURCHASABLE. A freshly created (or previously drafted) base plan sits in DRAFT + * and cannot be sold until activated. Play only permits activation once the app + * is PUBLISHED (an APK/AAB exists on at least one track), so this is best-effort: + * when the app isn't published yet Play rejects it, and we return the reason + * rather than throwing — the subscription itself already synced, and a later + * re-sync (after the APK lands) flips the plan ACTIVE. Idempotent: an + * already-active base plan counts as success. + */ +async function activateBasePlan( + token: string, + pkg: string, + productId: string, + basePlanId: string, +): Promise<{ activated: boolean; error?: string }> { + const res = await playFetch( + token, + `/applications/${pkg}/subscriptions/${encodeURIComponent(productId)}/basePlans/${encodeURIComponent(basePlanId)}:activate`, + { method: "POST", body: JSON.stringify({ packageName: pkg, productId, basePlanId }) }, + ) + if (res.ok) return { activated: true } + const body = await res.text() + // Already ACTIVE → the goal state is already met; treat as success. + if (/already active/i.test(body)) return { activated: true } + console.warn( + `[googleplay-product-sync] base plan ${basePlanId} not activated for ${productId} (${res.status}): ${body}`, + ) + return { + activated: false, + error: `base plan not activated (${res.status}): ${shortPlayError(body)}`, + } +} + export async function syncProductToGooglePlay( creds: GooglePlayCreds, paycraftProductId: string, // for logging correlation only @@ -161,7 +212,17 @@ export async function syncProductToGooglePlay( `[googleplay-product-sync] listing patch failed for ${productId} (${patchRes.status}): ${await patchRes.text()}`, ) } - return { playProductId: productId, basePlanId, created: false } + // Re-sync of an existing subscription: attempt to activate the base plan + // (a no-op if already active) — this is how a DRAFT plan goes live once the + // tenant has finally published the app on Play. + const act = await activateBasePlan(token, pkg, productId, basePlanId) + return { + playProductId: productId, + basePlanId, + created: false, + activated: act.activated, + activationError: act.error, + } } if (getRes.status !== 404) { @@ -229,5 +290,14 @@ export async function syncProductToGooglePlay( ) } - return { playProductId: productId, basePlanId, created: true } + // Created as DRAFT → activate so it's immediately purchasable. Best-effort: + // blocked until the app is published, in which case a later re-sync activates. + const act = await activateBasePlan(token, pkg, productId, basePlanId) + return { + playProductId: productId, + basePlanId, + created: true, + activated: act.activated, + activationError: act.error, + } } diff --git a/dashboard/lib/stripe-route-helper.ts b/dashboard/lib/stripe-route-helper.ts index 4761391..0bb81d0 100644 --- a/dashboard/lib/stripe-route-helper.ts +++ b/dashboard/lib/stripe-route-helper.ts @@ -226,7 +226,7 @@ export async function cashfreeSyncProduct( export async function googlePlaySyncProduct( supabase: ReturnType, opts: SyncOptions, -): Promise<{ error?: string }> { +): Promise<{ error?: string; warning?: string }> { const { tenantId, productId, body, existingPlayProductId } = opts try { if (body.type !== "subscription") return {} @@ -266,6 +266,17 @@ export async function googlePlaySyncProduct( p_play_product_id: result.playProductId, p_app_store_product_id: null, }) + // The product synced, but its base plan may still be DRAFT (Play blocks + // activation until the app is published). Surface that as a non-fatal + // warning so the operator knows to upload an APK + re-sync, rather than + // believing the subscription is already live/purchasable. + if (!result.activated) { + return { + warning: + result.activationError ?? + "synced, but the Play base plan is still DRAFT — publish the app on Play (upload an APK/AAB), then re-sync to activate it", + } + } return {} } catch (e: any) { const msg = e?.message ?? String(e) From 095369f727d29841093713f3acd7cfe4383a3b5a Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Sat, 25 Jul 2026 15:11:44 +0530 Subject: [PATCH 05/12] chore(source): update cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftInitializer.kt cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftPlatform.android.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftModule.kt (#126) --- .../paycraft/PayCraftInitializer.kt | 6 +++ .../paycraft/PayCraftPlatform.android.kt | 44 +++++++++++++++++++ .../PlatformNativeBillingClient.android.kt | 21 +++++++++ .../billing/PlatformNativeBillingClient.kt | 23 ++++++++++ .../paycraft/di/PayCraftModule.kt | 11 +++-- .../PlatformNativeBillingClient.ios.kt | 9 ++++ .../billing/PlatformNativeBillingClient.js.kt | 4 ++ .../PlatformNativeBillingClient.jvm.kt | 4 ++ .../PlatformNativeBillingClientJvmTest.kt | 17 +++++++ .../PlatformNativeBillingClient.wasmJs.kt | 4 ++ 10 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.android.kt create mode 100644 cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.kt create mode 100644 cmp-paycraft/src/iosMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.ios.kt create mode 100644 cmp-paycraft/src/jsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.js.kt create mode 100644 cmp-paycraft/src/jvmMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.jvm.kt create mode 100644 cmp-paycraft/src/jvmTest/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClientJvmTest.kt create mode 100644 cmp-paycraft/src/wasmJsMain/kotlin/com/mobilebytelabs/paycraft/billing/PlatformNativeBillingClient.wasmJs.kt 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/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/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/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/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 From c0def04abd8d5d76307592a7a5fd8d12a2dd183c Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Sat, 25 Jul 2026 15:14:52 +0530 Subject: [PATCH 06/12] =?UTF-8?q?chore(release):=20bump=20paycraft=202.2.0?= =?UTF-8?q?=20=E2=86=92=202.3.0=20(commonMain-only=20native=20billing)=20(?= =?UTF-8?q?#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(source): update cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftInitializer.kt cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/PayCraftPlatform.android.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftModule.kt * chore(source): update gradle.properties --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 2b3f554..e13f626 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,6 +14,6 @@ org.jetbrains.compose.experimental.macos.enabled=true android.useAndroidX=true android.nonTransitiveRClass=true #Publishing -paycraft.version=2.2.0 +paycraft.version=2.3.0 SONATYPE_HOST=CENTRAL_PORTAL SONATYPE_AUTOMATIC_RELEASE=true From 8c7073e65126ec2dd1850aeb65e587970aaea48a Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Wed, 29 Jul 2026 17:57:02 +0530 Subject: [PATCH 07/12] feat paywall currency fix native store price and usd fallback (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initialize session branch — feat paywall currency fix native store price and usd fallback * chore(source): update cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlayBillingNativeClient.android.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt * fix(billing): resolve CI blockers on native-billing PR — Android getBillingConfig + ktlint - PlayBillingNativeClient.android: billing-ktx v8 has no suspend getBillingConfig extension; rewrite storefrontCountry() with the callback getBillingConfigAsync wrapped in suspendCancellableCoroutine (fixes 'unresolved reference getBillingConfig' + type inference) - PayCraft.kt: drop leading underscore on two purely-private vars (nativePricesBySku, currentSuite) that have no matching public member — ktlint standard:backing-property-naming was aborting spotless with an AssertionError - spotless reformat of touched files Verified green locally: spotlessCheck, :cmp-paycraft:compileAndroidMain, :cmp-paycraft:compileKotlinIosSimulatorArm64 (native StoreKit2), :cmp-paycraft:jvmTest. --- .../PlayBillingNativeClient.android.kt | 51 +++++ .../paycraft/CurrencyResolver.kt | 30 ++- .../com/mobilebytelabs/paycraft/PayCraft.kt | 184 ++++++++++++++---- .../paycraft/billing/CheckoutLane.kt | 78 ++++++-- .../paycraft/billing/NativeBillingClient.kt | 40 ++++ .../paycraft/core/BillingManager.kt | 20 ++ .../paycraft/core/PayCraftBillingManager.kt | 133 ++++++++++--- .../paycraft/model/ProductPricing.kt | 22 ++- .../paycraft/CurrencyResolverTest.kt | 94 ++++++++- .../paycraft/billing/CheckoutRoutingTest.kt | 50 ++++- .../core/PayCraftBillingManagerTest.kt | 59 +++++- .../paycraft/model/ProductTest.kt | 52 ++++- .../testsupport/EntitlementTestSupport.kt | 4 + .../paycraft/ui/PayCraftRestoreContentTest.kt | 1 + .../paycraft/billing/StoreKit2Bridge.kt | 24 +++ .../StoreKit2NativeBillingClient.ios.kt | 7 + .../src/iosMain/swift/PayCraftStoreKit2.swift | 41 +++- .../lib/appstore-product-sync.test.ts | 67 +++++++ dashboard/lib/appstore-product-sync.ts | 5 +- gradle.properties | 2 +- 20 files changed, 846 insertions(+), 118 deletions(-) create mode 100644 dashboard/__tests__/lib/appstore-product-sync.test.ts 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/CurrencyResolver.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt index 64068fa..df8018a 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,26 @@ 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 STORE STOREFRONT before device: + * [override] (InitOptions.localeOverride) → [storeStorefront] (Play `getBillingConfig` + * countryCode / StoreKit `Storefront.current` countryCode) → [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 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. */ - 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?, + ): String = override?.trim()?.takeIf { it.isNotBlank() } + ?: storeStorefront?.trim()?.takeIf { it.isNotBlank() } + ?: deviceCountry?.trim()?.takeIf { it.isNotBlank() } + ?: configLocale?.trim()?.takeIf { it.isNotBlank() } + ?: DEFAULT_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..b8ec414 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, ) @@ -282,6 +307,11 @@ object PayCraft { .copy(fetchedAtEpochMillis = currentTimeMillis()) 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 +321,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 +387,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 +474,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 +490,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 +505,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 +517,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 +542,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) } /** @@ -510,7 +610,11 @@ enum class ConfigSource { Cloud, SelfHosted, Mock } * legacy single-provider field. Multi-provider apps consume `SuiteConfig.providers` * directly via the bottom-sheet picker. */ -internal fun SuiteConfig.toPayCraftConfig(backend: PayCraftBackend, apiKey: String?): PayCraftConfig { +internal fun SuiteConfig.toPayCraftConfig( + backend: PayCraftBackend, + apiKey: String?, + nativePricesBySku: Map = emptyMap(), +): PayCraftConfig { val firstProvider = providers.firstOrNull() val provider: PaymentProvider = if (firstProvider != null) { SuiteProviderAdapter(firstProvider) @@ -521,7 +625,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 +637,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 +652,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 +677,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/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/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/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/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/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/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/lib/appstore-product-sync.ts b/dashboard/lib/appstore-product-sync.ts index 045dba3..9a2623b 100644 --- a/dashboard/lib/appstore-product-sync.ts +++ b/dashboard/lib/appstore-product-sync.ts @@ -268,7 +268,10 @@ export async function syncProductToAppStore( groupLevel: 1, }, relationships: { - subscriptionGroup: { + // ASC keys this relationship `group` (linking a `subscriptionGroups` + // resource) — NOT `subscriptionGroup`. The wrong key produced a 409 + // ENTITY_ERROR.RELATIONSHIP.UNKNOWN + a missing-required `group` error. + group: { data: { type: "subscriptionGroups", id: groupId }, }, }, diff --git a/gradle.properties b/gradle.properties index e13f626..1516037 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,6 +14,6 @@ org.jetbrains.compose.experimental.macos.enabled=true android.useAndroidX=true android.nonTransitiveRClass=true #Publishing -paycraft.version=2.3.0 +paycraft.version=2.3.1 SONATYPE_HOST=CENTRAL_PORTAL SONATYPE_AUTOMATIC_RELEASE=true From 11064d5c727f3e22b5540f5405b91e20b0e6bad8 Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Fri, 31 Jul 2026 19:32:41 +0530 Subject: [PATCH 08/12] =?UTF-8?q?chore:=20initialize=20session=20branch=20?= =?UTF-8?q?=E2=80=94=20chore(paycraft):=20reconcile=20+=20close=20v2=20pro?= =?UTF-8?q?duction-readiness=20epic=20(OBE=20by=202.3.x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e3c8b1f4450f736be823609f0c99b804797bcf09 Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Sat, 1 Aug 2026 20:55:28 +0530 Subject: [PATCH 09/12] chore(source): update .gitignore CHANGELOG.md cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt --- .gitignore | 7 +- CHANGELOG.md | 13 +++ .../paycraft/CountryDetector.kt | 51 +++++++++++ .../paycraft/CurrencyResolver.kt | 37 +++++--- .../com/mobilebytelabs/paycraft/PayCraft.kt | 32 ++++++- .../paycraft/config/SuiteConfig.kt | 17 ++++ .../paycraft/CountryDetectorTest.kt | 50 +++++++++++ .../paycraft/ProviderSelectionTest.kt | 33 +++++++ .../lib/checkout-router.platform.test.ts | 41 +++++++++ .../(dashboard)/providers/routing/page.tsx | 8 +- dashboard/app/api/routing-rules/route.ts | 10 ++- .../providers/routing-rules-editor.tsx | 23 ++++- dashboard/lib/checkout-router.ts | 25 +++++- idea-layer/state/AGENT_AWARENESS.jsonl | 1 + supabase/functions/config/index.ts | 88 ++++++++++++++++++- .../075_routing_platform_dimension.sql | 81 +++++++++++++++++ 16 files changed, 492 insertions(+), 25 deletions(-) create mode 100644 cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CountryDetector.kt create mode 100644 cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/CountryDetectorTest.kt create mode 100644 cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/ProviderSelectionTest.kt create mode 100644 dashboard/__tests__/lib/checkout-router.platform.test.ts create mode 100644 supabase/migrations/075_routing_platform_dimension.sql 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/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/commonMain/kotlin/com/mobilebytelabs/paycraft/CountryDetector.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CountryDetector.kt new file mode 100644 index 0000000..a31fd29 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CountryDetector.kt @@ -0,0 +1,51 @@ +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 df8018a..709c7fc 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/CurrencyResolver.kt @@ -33,26 +33,39 @@ object CurrencyResolver { const val FALLBACK_CURRENCY = "USD" /** - * Decide the billing country once, override-wins, then STORE STOREFRONT before device: - * [override] (InitOptions.localeOverride) → [storeStorefront] (Play `getBillingConfig` - * countryCode / StoreKit `Storefront.current` countryCode) → [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 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. + * 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?, storeStorefront: String?, deviceCountry: String?, configLocale: String?, - ): String = override?.trim()?.takeIf { it.isNotBlank() } - ?: storeStorefront?.trim()?.takeIf { it.isNotBlank() } - ?: deviceCountry?.trim()?.takeIf { it.isNotBlank() } - ?: configLocale?.trim()?.takeIf { it.isNotBlank() } - ?: DEFAULT_COUNTRY + 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 b8ec414..4ff0039 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt @@ -290,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( @@ -305,6 +309,17 @@ 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 @@ -604,18 +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?, nativePricesBySku: Map = emptyMap(), ): PayCraftConfig { - val firstProvider = providers.firstOrNull() + val firstProvider = primaryProvider() val provider: PaymentProvider = if (firstProvider != null) { SuiteProviderAdapter(firstProvider) } else { 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/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/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/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/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/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/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({
    -
    +
    +