Skip to content

Commit eb5b055

Browse files
refactor(dashboard): simplify platform providers to primary + fallback list (#142)
* chore: initialize session branch — refactor(dashboard): simplify platform providers to primary + fallback list * chore(source): update dashboard/app/(dashboard)/providers/platform/page.tsx dashboard/components/providers/platform-providers-panel.tsx
1 parent d9f115b commit eb5b055

2 files changed

Lines changed: 158 additions & 121 deletions

File tree

dashboard/app/(dashboard)/providers/platform/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default async function PlatformProvidersPage() {
3030
<div>
3131
<PageHeader
3232
title="Platform providers"
33-
subtitle="Choose which payment provider each platform of your app uses — cheapest first, with fees shown. iOS and Android digital subscriptions use the native store automatically; Desktop and Web are yours to route."
33+
subtitle="Set your primary payment provider for web & desktop checkout — every other connected provider becomes an automatic fallback. iOS and Android digital subscriptions always use the native store."
3434
/>
3535
<PlatformProvidersPanel
3636
registry={(registryRes.data ?? []) as any}

dashboard/components/providers/platform-providers-panel.tsx

Lines changed: 157 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,57 @@
11
"use client"
22

33
import { useState } from "react"
4+
import Link from "next/link"
45
import { Card, CardBody } from "@/components/ui/card"
56
import { Badge } from "@/components/ui/badge"
67

78
/**
8-
* Platform → provider selector (migration 075 routing engine, surfaced as a first-class panel).
9+
* Primary provider + fallback (migration 075 routing engine, simplified).
910
*
10-
* Lets a merchant pick which payment provider each app platform uses, WITH each provider's fee shown
11-
* so they can route to the cheapest connection:
12-
* - iOS / Android — digital subscriptions ALWAYS transact through the native store (App Store /
13-
* Google Play) per Apple 3.1.1 / Google Play Payments policy (enforced by the SDK compliance lane,
14-
* not routable). The dropdown sets the WEB-lane / physical-goods fallback provider.
15-
* - Desktop / Web — no native store, so the merchant picks the PSP freely.
11+
* One list of supported providers, each with connection status. The merchant marks ONE as PRIMARY;
12+
* every other connected provider becomes an automatic fallback (cheapest first) if the primary is
13+
* unavailable for a given customer. Saved as a single global routing rule whose ordered
14+
* `priority_methods` is `[primary, ...fallbacks]`.
1615
*
17-
* Defaults: when a platform has no explicit rule, the dropdown shows the CHEAPEST connected provider
18-
* (the router's actual default), named + with its fee, so it's never ambiguous. Choosing a specific
19-
* provider writes/replaces a `platform=<p>` routing rule; choosing "Cheapest" removes it.
16+
* iOS/Android digital subscriptions always transact through the native store (App Store / Google
17+
* Play) per store policy — those are shown separately as automatic, not part of the primary/fallback
18+
* ordering (which governs web/desktop checkout).
2019
*/
2120

2221
interface RegistryRow {
2322
method: string
2423
provider: string
25-
display_name: string
2624
fee_percent: number | null
2725
}
2826
interface RoutingRule {
2927
id: string
3028
platform: string | null
3129
priority_methods: string[]
30+
country_code?: string | null
31+
currency?: string | null
32+
product_type?: string | null
3233
}
3334

34-
const PLATFORMS: { key: string; label: string; native?: string; hint: string }[] = [
35-
{ key: "ios", label: "iOS", native: "App Store (StoreKit 2)", hint: "Digital subscriptions use the App Store automatically (Apple 3.1.1). Dropdown sets the web / physical-goods fallback." },
36-
{ key: "android", label: "Android", native: "Google Play Billing", hint: "Digital subscriptions use Google Play automatically (Payments policy). Dropdown sets the web / physical-goods fallback." },
37-
{ key: "desktop", label: "Desktop", hint: "No native store — pick the provider used for checkout on desktop." },
38-
{ key: "web", label: "Web", hint: "No native store — pick the provider used for checkout on the web." },
39-
]
40-
41-
function prettyProvider(p: string) {
42-
return p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")
35+
const DISPLAY: Record<string, string> = {
36+
stripe: "Stripe",
37+
razorpay: "Razorpay",
38+
cashfree: "Cashfree",
39+
direct_upi: "UPI Direct",
40+
google_play: "Google Play Billing",
41+
app_store: "App Store (StoreKit 2)",
42+
}
43+
const CONNECT_HREF: Record<string, string> = {
44+
stripe: "/providers/stripe",
45+
razorpay: "/providers/razorpay",
46+
cashfree: "/providers/cashfree",
47+
direct_upi: "/providers/upi",
48+
google_play: "/providers/google-play",
49+
app_store: "/providers/app-store",
4350
}
51+
const NATIVE: { key: string; on: string }[] = [
52+
{ key: "google_play", on: "Android" },
53+
{ key: "app_store", on: "iOS" },
54+
]
4455

4556
export function PlatformProvidersPanel({
4657
registry,
@@ -51,141 +62,167 @@ export function PlatformProvidersPanel({
5162
connectedProviders: string[]
5263
initialRules: RoutingRule[]
5364
}) {
54-
// provider → { cheapest method, its fee% }. Registry is fee-sorted, so the first row per provider
55-
// is its cheapest method.
65+
const connected = new Set(connectedProviders)
66+
67+
// provider → { cheapest method, fee } from the fee-sorted registry (web providers only)
5668
const providerInfo = new Map<string, { method: string; fee: number }>()
5769
for (const r of registry) {
5870
if (!providerInfo.has(r.provider)) providerInfo.set(r.provider, { method: r.method, fee: r.fee_percent ?? 99 })
5971
}
60-
// connected providers that have a routable web method, sorted CHEAPEST first
61-
const options = [...new Set(connectedProviders)]
62-
.filter((p) => providerInfo.has(p))
63-
.sort((a, b) => (providerInfo.get(a)!.fee) - (providerInfo.get(b)!.fee))
64-
const cheapest = options[0] ?? null
72+
// all supported WEB providers, cheapest first
73+
const webProviders = [...providerInfo.keys()].sort((a, b) => providerInfo.get(a)!.fee - providerInfo.get(b)!.fee)
6574

66-
// current explicit platform → provider, from the rules
67-
const initial: Record<string, string> = {}
68-
const ruleIdByPlatform = new Map<string, string>()
69-
for (const rule of initialRules) {
70-
if (!rule.platform || rule.platform === "any") continue
71-
ruleIdByPlatform.set(rule.platform, rule.id)
72-
const provider = registry.find((r) => r.method === rule.priority_methods?.[0])?.provider
73-
if (provider) initial[rule.platform] = provider
74-
}
75+
// rules this panel manages = the "global" ones (no country/currency/product scoping)
76+
const managedRuleIds = initialRules
77+
.filter((r) => !r.country_code && !r.currency && !r.product_type)
78+
.map((r) => r.id)
79+
// current primary = first method of the managed rule that carries an ordered list
80+
const managedWithMethods = initialRules.find(
81+
(r) => !r.country_code && !r.currency && !r.product_type && (r.priority_methods?.length ?? 0) > 0,
82+
)
83+
const initialPrimary =
84+
(managedWithMethods && registry.find((r) => r.method === managedWithMethods.priority_methods[0])?.provider) || ""
7585

76-
const [selection, setSelection] = useState<Record<string, string>>(initial)
77-
const [saving, setSaving] = useState<string | null>(null)
78-
const [saved, setSaved] = useState<string | null>(null)
86+
const [primary, setPrimary] = useState<string>(initialPrimary)
87+
const [ruleIds, setRuleIds] = useState<string[]>(managedRuleIds)
88+
const [saving, setSaving] = useState(false)
89+
const [saved, setSaved] = useState(false)
7990
const [error, setError] = useState<string | null>(null)
8091

81-
const feeLabel = (p: string) => {
92+
const fee = (p: string) => {
8293
const f = providerInfo.get(p)?.fee
8394
return f != null && f < 99 ? `${f}%` : "—"
8495
}
96+
const pretty = (p: string) => DISPLAY[p] ?? p
8597

86-
async function save(platform: string, provider: string) {
98+
async function choosePrimary(provider: string) {
99+
if (!connected.has(provider) || saving) return
87100
setError(null)
88-
setSaving(platform)
89-
setSelection((s) => ({ ...s, [platform]: provider }))
101+
setSaving(true)
102+
setSaved(false)
103+
const prev = primary
104+
setPrimary(provider)
90105
try {
91-
const existingId = ruleIdByPlatform.get(platform)
92-
if (existingId) {
93-
await fetch(`/api/routing-rules/${existingId}`, { method: "DELETE" })
94-
ruleIdByPlatform.delete(platform)
106+
// Replace the managed global rule: delete existing, then create one ordered rule.
107+
for (const id of ruleIds) {
108+
await fetch(`/api/routing-rules/${id}`, { method: "DELETE" }).catch(() => {})
95109
}
96-
if (provider) {
97-
const method = providerInfo.get(provider)?.method
98-
const res = await fetch("/api/routing-rules", {
99-
method: "POST",
100-
headers: { "content-type": "application/json" },
101-
body: JSON.stringify({ platform, priority_methods: method ? [method] : [], country_code: null, currency: null, product_type: null, priority: 50 }),
102-
})
103-
if (!res.ok) {
104-
const j = await res.json().catch(() => ({}))
105-
throw new Error(j?.error ?? `save failed (${res.status})`)
106-
}
107-
const { id } = await res.json()
108-
if (id) ruleIdByPlatform.set(platform, id)
110+
// primary first, then the other CONNECTED web providers (cheapest first) as fallback
111+
const ordered = [provider, ...webProviders.filter((p) => p !== provider && connected.has(p))]
112+
const methods = ordered.map((p) => providerInfo.get(p)?.method).filter(Boolean) as string[]
113+
const res = await fetch("/api/routing-rules", {
114+
method: "POST",
115+
headers: { "content-type": "application/json" },
116+
body: JSON.stringify({ platform: "any", priority_methods: methods, country_code: null, currency: null, product_type: null, priority: 10 }),
117+
})
118+
if (!res.ok) {
119+
const j = await res.json().catch(() => ({}))
120+
throw new Error(j?.error ?? `save failed (${res.status})`)
109121
}
110-
setSaved(platform)
111-
setTimeout(() => setSaved((cur) => (cur === platform ? null : cur)), 2500)
122+
const { id } = await res.json()
123+
setRuleIds(id ? [id] : [])
124+
setSaved(true)
125+
setTimeout(() => setSaved(false), 2500)
112126
} catch (e: any) {
113-
setError(`${platform}: ${e?.message ?? "save failed"}`)
127+
setPrimary(prev)
128+
setError(e?.message ?? "save failed")
114129
} finally {
115-
setSaving(null)
130+
setSaving(false)
116131
}
117132
}
118133

119-
const cheapestOptionLabel = cheapest
120-
? `Auto — cheapest: ${prettyProvider(cheapest)} · ${feeLabel(cheapest)}`
121-
: "Auto (cheapest eligible)"
134+
// fallback rank among connected web providers (primary excluded)
135+
const fallbackOrder = webProviders.filter((p) => p !== primary && connected.has(p))
122136

123137
return (
124138
<Card>
125139
<CardBody>
126140
<div className="flex items-center justify-between mb-1">
127-
<h2 className="text-sm font-bold text-ink-900">Platform providers</h2>
128-
<Badge>fees shown</Badge>
141+
<h2 className="text-sm font-bold text-ink-900">Payment providers</h2>
142+
{saved && <span className="text-xs font-semibold text-emerald-600">✓ updated</span>}
129143
</div>
130144
<p className="text-xs text-ink-500 mb-4 max-w-2xl">
131-
Pick a provider to set it as the <strong>primary</strong> for that platform — it saves
132-
instantly and you can change it anytime. Leave it on <strong>Auto</strong> to let the router
133-
use the cheapest eligible provider. Fees are shown so you route to the connection that saves
134-
the most. iOS and Android digital subscriptions use the native store automatically; Desktop
135-
and Web are yours to route.
145+
Pick your <strong>primary</strong> provider for web &amp; desktop checkout. Every other
146+
connected provider becomes an automatic <strong>fallback</strong> (cheapest first) if the
147+
primary can't serve a customer. iOS &amp; Android digital subscriptions always use the
148+
native store per store policy.
136149
</p>
137150

138-
<div className="space-y-2.5">
139-
{PLATFORMS.map((p) => {
140-
const selected = selection[p.key] ?? "" // "" = cheapest/default
151+
{/* Primary + fallback list */}
152+
<div className="rounded-xl border border-ink-200 divide-y divide-ink-100">
153+
{webProviders.map((p) => {
154+
const isConnected = connected.has(p)
155+
const isPrimary = primary === p
156+
const fbIndex = fallbackOrder.indexOf(p)
141157
return (
142-
<div key={p.key} className="grid grid-cols-[110px_1fr] items-center gap-3 py-2 border-t border-ink-100 first:border-t-0">
143-
<span className="text-sm font-bold text-ink-900">{p.label}</span>
144-
<div className="space-y-1">
145-
<div className="flex items-center gap-2 flex-wrap">
146-
{p.native && (
147-
<span className="inline-flex items-center gap-1 text-xs font-semibold text-ink-700 bg-ink-50 border border-ink-200 rounded-md px-2 py-1">
148-
{p.native}
149-
<Badge>auto</Badge>
150-
</span>
151-
)}
152-
<select
153-
value={selected}
154-
disabled={saving === p.key || options.length === 0}
155-
onChange={(e) => save(p.key, e.target.value)}
156-
className="min-w-[220px] px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:border-brand-500 disabled:opacity-60"
157-
aria-label={`${p.label} provider`}
158-
>
159-
<option value="">{p.native ? `Web fallback — ${cheapestOptionLabel}` : cheapestOptionLabel}</option>
160-
{options.map((prov) => (
161-
<option key={prov} value={prov}>
162-
{prettyProvider(prov)} · {feeLabel(prov)}
163-
</option>
164-
))}
165-
</select>
166-
{saving === p.key && <span className="text-xs text-ink-400">saving…</span>}
167-
{saved === p.key && <span className="text-xs font-semibold text-emerald-600">✓ updated</span>}
168-
{selected && saving !== p.key && saved !== p.key && (
169-
<span className="text-[10px] font-bold uppercase tracking-wide text-brand-700 bg-brand-50 border border-brand-200 rounded px-1.5 py-0.5">Primary</span>
170-
)}
171-
</div>
172-
<p className="text-[11px] text-ink-400">{p.hint}</p>
173-
</div>
174-
</div>
158+
<button
159+
key={p}
160+
type="button"
161+
disabled={!isConnected || saving}
162+
onClick={() => choosePrimary(p)}
163+
className="w-full flex items-center gap-3 px-4 py-3 text-left disabled:cursor-not-allowed hover:bg-ink-50/60 disabled:hover:bg-transparent"
164+
>
165+
<span
166+
className={
167+
"w-4 h-4 rounded-full border flex items-center justify-center flex-shrink-0 " +
168+
(isPrimary ? "border-brand-500 bg-brand-500" : "border-ink-300")
169+
}
170+
>
171+
{isPrimary && <span className="w-1.5 h-1.5 rounded-full bg-white" />}
172+
</span>
173+
<span className="text-sm font-semibold text-ink-900 w-40">{pretty(p)}</span>
174+
<span className="text-xs text-ink-500 w-16">{fee(p)}</span>
175+
<span className="flex-1" />
176+
{isConnected ? (
177+
isPrimary ? (
178+
<span className="text-[10px] font-bold uppercase tracking-wide text-brand-700 bg-brand-50 border border-brand-200 rounded px-1.5 py-0.5">Primary</span>
179+
) : primary && fbIndex >= 0 ? (
180+
<span className="text-[11px] text-ink-400">Fallback {fbIndex + 1}</span>
181+
) : (
182+
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-600">
183+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Connected
184+
</span>
185+
)
186+
) : (
187+
<Link
188+
href={CONNECT_HREF[p] ?? "/providers"}
189+
onClick={(e) => e.stopPropagation()}
190+
className="text-[11px] font-semibold text-brand-600 hover:text-brand-700"
191+
>
192+
Connect →
193+
</Link>
194+
)}
195+
</button>
175196
)
176197
})}
177198
</div>
199+
{saving && <p className="text-xs text-ink-400 mt-2">saving…</p>}
200+
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
178201

179-
{options.length === 0 ? (
180-
<p className="text-xs text-amber-600 mt-3">
181-
Connect a payment provider (Stripe, Razorpay, …) below — then you can route Desktop/Web to it and compare fees.
182-
</p>
183-
) : (
184-
<p className="text-[11px] text-ink-400 mt-3">
185-
Fees are the provider's domestic rate from the method registry — lower routes cheaper. Cross-border adds each provider's FX markup.
186-
</p>
187-
)}
188-
{error && <p className="text-xs text-red-600 mt-3">{error}</p>}
202+
{/* Native in-app billing (automatic) */}
203+
<h3 className="text-[11px] font-bold uppercase tracking-wider text-ink-400 mt-6 mb-2">In-app billing (automatic)</h3>
204+
<div className="rounded-xl border border-ink-200 divide-y divide-ink-100">
205+
{NATIVE.map((n) => (
206+
<div key={n.key} className="flex items-center gap-3 px-4 py-3">
207+
<span className="text-sm font-semibold text-ink-900 w-40">{pretty(n.key)}</span>
208+
<span className="text-[11px] text-ink-400">Auto on {n.on}</span>
209+
<span className="flex-1" />
210+
{connected.has(n.key) ? (
211+
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-600">
212+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Connected
213+
</span>
214+
) : (
215+
<Link href={CONNECT_HREF[n.key] ?? "/providers"} className="text-[11px] font-semibold text-brand-600 hover:text-brand-700">
216+
Connect →
217+
</Link>
218+
)}
219+
</div>
220+
))}
221+
</div>
222+
<p className="text-[11px] text-ink-400 mt-3">
223+
Fees are the provider's domestic rate — lower routes cheaper. Cross-border adds each provider's FX markup.
224+
Digital subscriptions on iOS/Android always use the native store (Apple 3.1.1 / Google Play policy).
225+
</p>
189226
</CardBody>
190227
</Card>
191228
)

0 commit comments

Comments
 (0)