From 0a3cb2f45590cd4ec745638e816ef17e0c064a29 Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Mon, 3 Aug 2026 16:30:27 +0530 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20initialize=20session=20branch=20?= =?UTF-8?q?=E2=80=94=20feat(dashboard):=20editable=20primary=20provider=20?= =?UTF-8?q?per=20platform=20incl=20native=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From d9688974a1667058524a7279af2aefb66c9985d2 Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Mon, 3 Aug 2026 16:30:42 +0530 Subject: [PATCH 2/2] chore(source): update .gitignore dashboard/components/providers/platform-providers-panel.tsx supabase/migrations/076_native_store_methods.sql --- .gitignore | 1 + .../providers/platform-providers-panel.tsx | 107 ++++++++---------- .../migrations/076_native_store_methods.sql | 44 +++++++ 3 files changed, 95 insertions(+), 57 deletions(-) create mode 100644 supabase/migrations/076_native_store_methods.sql diff --git a/.gitignore b/.gitignore index a616c86..f9494bb 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,7 @@ cli/dist/ # adding a matching `!supabase/migrations/NNN_*.sql` line. supabase/migrations/* !supabase/migrations/075_routing_platform_dimension.sql +!supabase/migrations/076_native_store_methods.sql .vercel # Wrangler CLI cache (Cloudflare Workers) diff --git a/dashboard/components/providers/platform-providers-panel.tsx b/dashboard/components/providers/platform-providers-panel.tsx index 167e88b..9896af2 100644 --- a/dashboard/components/providers/platform-providers-panel.tsx +++ b/dashboard/components/providers/platform-providers-panel.tsx @@ -76,20 +76,28 @@ export function PlatformProvidersPanel({ initialRules: RoutingRule[] }) { const connected = new Set(connectedProviders) + const WEB = new Set(["stripe", "razorpay", "cashfree", "direct_upi"]) - // provider → { cheapest method, fee } from the fee-sorted registry (web providers only) + // provider → { cheapest method, fee } from the fee-sorted registry (includes native store methods) const providerInfo = new Map() for (const r of registry) { if (!providerInfo.has(r.provider)) providerInfo.set(r.provider, { method: r.method, fee: r.fee_percent ?? 99 }) } const methodToProvider = new Map() for (const r of registry) methodToProvider.set(r.method, r.provider) - // connected web providers, cheapest first — the options for both dropdowns - const options = [...providerInfo.keys()] - .filter((p) => connected.has(p)) + // connected WEB providers, cheapest first — the routable options for the web lane + const webOptions = [...providerInfo.keys()] + .filter((p) => WEB.has(p) && connected.has(p)) .sort((a, b) => providerInfo.get(a)!.fee - providerInfo.get(b)!.fee) + // Primary options per platform: native platforms lead with their native store, then web providers. + const primaryOptions = (platform: string) => { + const nk = PLATFORMS.find((p) => p.key === platform)?.nativeKey + return nk ? [nk, ...webOptions] : webOptions + } + const fee = (p: string) => { + if (!WEB.has(p)) return " · native" const f = providerInfo.get(p)?.fee return f != null && f < 99 ? ` · ${f}%` : "" } @@ -103,15 +111,10 @@ export function PlatformProvidersPanel({ if (!plat || plat === "any") continue if (rule.country_code || rule.currency || rule.product_type) continue ruleIdByPlatform[plat] = rule.id - if (isNativePlatform(plat)) { - // native store is the fixed primary; the single stored web method is the web-lane fallback - const fallback = methodToProvider.get(rule.priority_methods?.[0] ?? "") ?? "" - initSel[plat] = { primary: "", fallback } - } else { - const primary = methodToProvider.get(rule.priority_methods?.[0] ?? "") ?? "" - const fallback = methodToProvider.get(rule.priority_methods?.[1] ?? "") ?? "" - initSel[plat] = { primary, fallback } - } + // priority_methods = [primary, fallback] uniformly (native methods are valid registry entries) + const primary = methodToProvider.get(rule.priority_methods?.[0] ?? "") ?? "" + const fallback = methodToProvider.get(rule.priority_methods?.[1] ?? "") ?? "" + initSel[plat] = { primary, fallback } } const [sel, setSel] = useState>(initSel) @@ -135,10 +138,13 @@ export function PlatformProvidersPanel({ return c }) } - // ordered providers → methods. Native platforms: only the web-lane provider (native store is the - // fixed primary, not a routable method). Desktop/Web: [primary, fallback]. - const ordered = isNativePlatform(platform) ? [next.fallback] : [next.primary, next.fallback] - const providers = ordered.filter((p, i, a) => p && connected.has(p) && a.indexOf(p) === i) + // ordered [primary, fallback] → methods, uniform across platforms. A native store primary + // (app_store/google_play) is a valid registry method (migration 076). Fallback must be a + // connected web provider; the native primary is always available on its platform. + const providers = [next.primary, next.fallback].filter((p, i, a) => { + if (!p || a.indexOf(p) !== i) return false + return WEB.has(p) ? connected.has(p) : true // web needs a connected account; native store is intrinsic + }) const methods = providers.map((p) => providerInfo.get(p)?.method).filter(Boolean) as string[] if (methods.length > 0) { const res = await fetch("/api/routing-rules", { @@ -162,7 +168,7 @@ export function PlatformProvidersPanel({ } } - const noProviders = options.length === 0 + const noProviders = webOptions.length === 0 // fee label for the catalog: web → registry %, native → store-cut range const catalogFee = (c: (typeof CATALOG)[number]) => { @@ -178,7 +184,7 @@ export function PlatformProvidersPanel({

Supported providers

- {options.length + CATALOG.filter((c) => c.lane === "native" && connected.has(c.key)).length} connected + {CATALOG.filter((c) => connected.has(c.key)).length} connected

Every provider PayCraft supports. Connect the ones you want, then choose a primary + @@ -237,10 +243,10 @@ export function PlatformProvidersPanel({ primary + fallback

- iOS & Android use the native store (App Store / Google Play) as the - primary for digital subscriptions — mandatory per store policy — and the web-lane dropdown - handles physical goods there. Desktop & Web pick a primary + optional - fallback web provider. Saves instantly. + Pick the primary provider and an optional fallback for + each platform. iOS & Android default to the native store (App Store / Google Play) — + the required lane for digital subscriptions — but you can change it; the fallback web + provider then serves physical goods / non-digital. Saves instantly.

{noProviders ? ( @@ -255,56 +261,43 @@ export function PlatformProvidersPanel({
Platform Primary - Fallback / web-lane + Fallback
{PLATFORMS.map((p) => { const cur = sel[p.key] ?? { primary: "", fallback: "" } const native = isNativePlatform(p.key) - const nativeConnected = p.nativeKey ? connected.has(p.nativeKey) : false + // native platforms default-show their native store as primary until the merchant changes it + const primaryValue = cur.primary || (native ? (p.nativeKey ?? "") : "") + const primOpts = primaryOptions(p.key) return (
{p.label}
- {/* Primary */} - {native ? ( - // native store is the mandatory primary for iOS/Android digital — locked, not routable -
- {p.native} - native · auto - {nativeConnected ? ( - - connected - - ) : ( - connect → - )} -
- ) : ( - save(p.key, { primary: v, fallback: cur.fallback === v ? "" : cur.fallback })} - /> - )} + {/* Primary — a real dropdown on every platform; native store leads on iOS/Android */} + save(p.key, { primary: v, fallback: cur.fallback === v ? "" : cur.fallback })} + /> - {/* Fallback / web-lane provider */} + {/* Fallback — a connected web provider (excludes the chosen primary) */}
o !== cur.primary)} + placeholder="None" + options={webOptions.filter((o) => o !== primaryValue)} pretty={pretty} fee={fee} - disabled={saving === p.key || (!native && !cur.primary)} - onChange={(v) => save(p.key, { primary: cur.primary, fallback: v })} + disabled={saving === p.key} + onChange={(v) => save(p.key, { primary: primaryValue, fallback: v })} /> {saving === p.key && saving…} {saved === p.key && ✓ saved} diff --git a/supabase/migrations/076_native_store_methods.sql b/supabase/migrations/076_native_store_methods.sql new file mode 100644 index 0000000..e92d457 --- /dev/null +++ b/supabase/migrations/076_native_store_methods.sql @@ -0,0 +1,44 @@ +-- 076_native_store_methods.sql +-- +-- Register the two native-store billing methods (Apple App Store / StoreKit 2 and Google Play +-- Billing) in provider_method_registry so they are VALID entries in tenant_routing_rules.priority_methods. +-- +-- Why: the Platform-providers dashboard lets a merchant pick the PRIMARY provider per platform. For +-- iOS/Android the primary is the native store (mandatory for digital subscriptions per store policy), +-- and the merchant must be able to SELECT it explicitly. tenant_routing_rules_upsert() validates every +-- method against this registry (migration 075), so without a registry row the native primary can't be +-- persisted. +-- +-- Safety: neither routing consumer transacts through these methods — +-- • checkout-router.ts tryMethod() switch has no app_store/google_play case → returns null → the +-- method is skipped and routing continues (native purchases go through the store SDK, never a URL). +-- • config/index.ts orderProvidersByPlatform() ranks providers by priority_methods; a native method +-- ranks the native provider, which is correct (SDK primaryProvider() = the native store on that +-- platform) and never affects web-provider ordering. +-- So these rows only make the native store a *selectable, persistable* primary — they add no checkout URL path. +-- +-- fee_percent is capped at 15 by the registry's fee_percent_sane CHECK; the store commission is really +-- 15–30% (small-business vs standard). We store 15 (the sane-max) and the dashboard renders the +-- "15–30% store cut" range in the catalog. Idempotent via ON CONFLICT. + +INSERT INTO public.provider_method_registry + (method, display_name, provider, supports_one_time, supports_subscription, + supported_countries, supported_currencies, fee_percent, fee_fixed_cents, + cross_border_markup_percent, notes) +VALUES + ('app_store', 'App Store (StoreKit 2)', 'app_store', true, true, + '{}', '{}', 15, 0, 0, + 'Apple StoreKit 2 in-app purchase. Mandatory for iOS digital subscriptions per App Store policy. 15% (small business) to 30% commission. Native-store method — not a routable checkout URL; the SDK transacts via StoreKit.'), + ('google_play', 'Google Play Billing', 'google_play', true, true, + '{}', '{}', 15, 0, 0, + 'Google Play Billing Library. Mandatory for Android digital subscriptions per Play policy. 15% (first $1M / subscriptions) to 30% commission. Native-store method — not a routable checkout URL; the SDK transacts via Play Billing.') +ON CONFLICT (method) DO UPDATE + SET display_name = EXCLUDED.display_name, + supports_one_time = EXCLUDED.supports_one_time, + supports_subscription = EXCLUDED.supports_subscription, + supported_countries = EXCLUDED.supported_countries, + supported_currencies = EXCLUDED.supported_currencies, + fee_percent = EXCLUDED.fee_percent, + fee_fixed_cents = EXCLUDED.fee_fixed_cents, + cross_border_markup_percent = EXCLUDED.cross_border_markup_percent, + notes = EXCLUDED.notes;