Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b5d86f6
fix(dashboard): surface Google Play + App Store on the Providers list…
therajanmaurya Jul 24, 2026
e7438ae
fix(dashboard): surface store sync in products UI (bulk + badges + Re…
therajanmaurya Jul 24, 2026
4ecf6c1
chore(source): update .github/workflows/deploy-cloud.yml dashboard/ap…
therajanmaurya Jul 25, 2026
59a92f0
chore(source): update dashboard/__tests__/lib/googleplay-product-sync…
therajanmaurya Jul 25, 2026
590170f
Merge pull request #125 from therajanmaurya/development
mobilebytesenseicommunity Jul 25, 2026
095369f
chore(source): update cmp-paycraft/src/androidMain/kotlin/com/mobileb…
therajanmaurya Jul 25, 2026
c0def04
chore(release): bump paycraft 2.2.0 → 2.3.0 (commonMain-only native b…
therajanmaurya Jul 25, 2026
8c7073e
feat paywall currency fix native store price and usd fallback (#128)
therajanmaurya Jul 29, 2026
11064d5
chore: initialize session branch — chore(paycraft): reconcile + close…
therajanmaurya Jul 31, 2026
e3c8b1f
chore(source): update .gitignore CHANGELOG.md cmp-paycraft/src/common…
therajanmaurya Aug 1, 2026
e1b59a8
chore(source): update cmp-paycraft/src/commonMain/kotlin/com/mobileby…
therajanmaurya Aug 2, 2026
9098577
Merge pull request #129 from therajanmaurya/session-PayCraft-20260731…
mobilebytesenseicommunity Aug 2, 2026
b26abce
chore: initialize session branch — fix(auth): multi-tenant login call…
therajanmaurya Aug 2, 2026
fd74340
chore(source): update dashboard/app/auth/callback/route.ts supabase/.…
therajanmaurya Aug 2, 2026
6d1c5cf
Merge pull request #131 from therajanmaurya/session-PayCraft-20260802…
mobilebytesenseicommunity Aug 2, 2026
60dc053
merge main (#123/#124 store-sync) into development — keep dev's super…
therajanmaurya Aug 2, 2026
c5d1fb4
feat(dashboard): platform→provider selector panel on providers page (…
therajanmaurya Aug 3, 2026
8fd7684
feat(dashboard): platform panel to top + fees + default provider (#134)
therajanmaurya Aug 3, 2026
13cb384
merge main into development — keep dev's providers/page.tsx (panel at…
therajanmaurya Aug 3, 2026
862677c
fix(dashboard): remove duplicate platform providers panel (#136)
therajanmaurya Aug 3, 2026
4ce7e7c
merge main into development — keep dev's single-panel providers/page.…
therajanmaurya Aug 3, 2026
6883326
feat(dashboard): platform providers as separate sidebar page (#138)
therajanmaurya Aug 3, 2026
bb6cfba
merge main into development — keep dev (separate platform-providers p…
therajanmaurya Aug 3, 2026
8bb7a87
feat(dashboard): platform panel — explicit primary provider + save co…
therajanmaurya Aug 3, 2026
d9f115b
merge main into development — keep dev's platform panel (primary prov…
therajanmaurya Aug 3, 2026
eb5b055
refactor(dashboard): simplify platform providers to primary + fallbac…
therajanmaurya Aug 3, 2026
441d9b2
merge main into development — keep dev's simplified primary+fallback …
therajanmaurya Aug 3, 2026
693e055
feat(dashboard): per-platform primary + fallback provider selectors (…
therajanmaurya Aug 3, 2026
2cbf36a
merge main into development — keep dev's per-platform primary+fallbac…
therajanmaurya Aug 3, 2026
bbe5d2f
feat(dashboard): supported-providers catalog with connection status (…
therajanmaurya Aug 3, 2026
0d1f4ba
merge main into development — keep dev's providers catalog + per-plat…
therajanmaurya Aug 3, 2026
a0a357b
fix(dashboard): native store as locked primary for iOS/Android (#148)
therajanmaurya Aug 3, 2026
5287e65
merge main into development — keep dev native-primary panel
therajanmaurya Aug 3, 2026
e1d5aff
feat(dashboard): editable primary provider per platform incl native s…
therajanmaurya Aug 3, 2026
f0fb816
merge main into development — keep dev editable-primary panel + migra…
therajanmaurya Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
107 changes: 50 additions & 57 deletions dashboard/components/providers/platform-providers-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { method: string; fee: number }>()
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<string, string>()
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}%` : ""
}
Expand All @@ -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<Record<string, Sel>>(initSel)
Expand All @@ -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", {
Expand All @@ -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]) => {
Expand All @@ -178,7 +184,7 @@ export function PlatformProvidersPanel({
<CardBody>
<div className="flex items-center justify-between mb-1">
<h2 className="text-sm font-bold text-ink-900">Supported providers</h2>
<Badge>{options.length + CATALOG.filter((c) => c.lane === "native" && connected.has(c.key)).length} connected</Badge>
<Badge>{CATALOG.filter((c) => connected.has(c.key)).length} connected</Badge>
</div>
<p className="text-xs text-ink-500 mb-4 max-w-2xl">
Every provider PayCraft supports. Connect the ones you want, then choose a primary +
Expand Down Expand Up @@ -237,10 +243,10 @@ export function PlatformProvidersPanel({
<Badge>primary + fallback</Badge>
</div>
<p className="text-xs text-ink-500 mb-4 max-w-2xl">
<strong>iOS &amp; Android</strong> 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. <strong>Desktop &amp; Web</strong> pick a primary + optional
fallback web provider. Saves instantly.
Pick the <strong>primary</strong> provider and an optional <strong>fallback</strong> for
each platform. iOS &amp; 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.
</p>

{noProviders ? (
Expand All @@ -255,56 +261,43 @@ export function PlatformProvidersPanel({
<div className="hidden sm:grid grid-cols-[120px_1fr_1fr] gap-3 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-ink-400">
<span>Platform</span>
<span>Primary</span>
<span>Fallback / web-lane</span>
<span>Fallback</span>
</div>
{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 (
<div key={p.key} className="grid grid-cols-1 sm:grid-cols-[120px_1fr_1fr] gap-3 px-4 py-3 items-center">
<div className="flex items-center">
<span className="text-sm font-bold text-ink-900">{p.label}</span>
</div>

{/* Primary */}
{native ? (
// native store is the mandatory primary for iOS/Android digital — locked, not routable
<div className="flex items-center gap-2 px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg">
<span className="text-sm font-semibold text-ink-800">{p.native}</span>
<Badge>native · auto</Badge>
{nativeConnected ? (
<span className="ml-auto inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> connected
</span>
) : (
<Link href={p.nativeHref ?? "#"} className="ml-auto text-[10px] font-semibold text-brand-600">connect →</Link>
)}
</div>
) : (
<ProviderSelect
label="Primary"
value={cur.primary}
placeholder="Auto — cheapest"
options={options}
pretty={pretty}
fee={fee}
disabled={saving === p.key}
onChange={(v) => save(p.key, { primary: v, fallback: cur.fallback === v ? "" : cur.fallback })}
/>
)}
{/* Primary — a real dropdown on every platform; native store leads on iOS/Android */}
<ProviderSelect
label="Primary"
value={primaryValue}
placeholder={native ? "Choose primary" : "Auto — cheapest"}
options={primOpts}
pretty={pretty}
fee={fee}
disabled={saving === p.key}
onChange={(v) => save(p.key, { primary: v, fallback: cur.fallback === v ? "" : cur.fallback })}
/>

{/* Fallback / web-lane provider */}
{/* Fallback — a connected web provider (excludes the chosen primary) */}
<div className="flex items-center gap-2">
<ProviderSelect
label={native ? "Web-lane provider" : "Fallback"}
label="Fallback"
value={cur.fallback}
placeholder={native ? "Web lane — none" : "None"}
options={native ? options : options.filter((o) => 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 && <span className="text-[11px] text-ink-400">saving…</span>}
{saved === p.key && <span className="text-[11px] font-semibold text-emerald-600">✓ saved</span>}
Expand Down
44 changes: 44 additions & 0 deletions supabase/migrations/076_native_store_methods.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading