11"use client"
22
33import { useState } from "react"
4+ import Link from "next/link"
45import { Card , CardBody } from "@/components/ui/card"
56import { 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
2221interface RegistryRow {
2322 method : string
2423 provider : string
25- display_name : string
2624 fee_percent : number | null
2725}
2826interface 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
4556export 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 & 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 & 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