Skip to content

Commit 0103523

Browse files
chore(source): update cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/network/PayCraftRealtime.kt
1 parent b705166 commit 0103523

3 files changed

Lines changed: 82 additions & 47 deletions

File tree

cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,23 @@ object PayCraft {
539539
realtime.ensureConfigChannel(tenantId) {
540540
configFetchJob = applicationScope.launch { runCatching { prefetchProducts() } }
541541
}
542+
refreshRealtimeIdentity()
543+
}
544+
545+
/**
546+
* (Re)bind the realtime ENTITLEMENT channel to the CURRENT buyer identity
547+
* (`email ?: deviceId`). Called on every config apply AND by [BillingManager]
548+
* whenever the identity changes — login (device-id → email) and logout
549+
* (email → device-id) — so an entitlement push always reaches the buyer's
550+
* channel. The realtime client removes the prior channel before subscribing
551+
* the new one, so the old user's pings stop after logout. No-op until a
552+
* SuiteConfig (hence tenant_id) has landed. Best-effort; failures leave the
553+
* TTL/foreground sync as the fallback.
554+
*/
555+
internal fun refreshRealtimeIdentity() {
556+
val tenantId = suiteConfig?.tenantId ?: return
557+
val koin = KoinPlatform.getKoinOrNull() ?: return
558+
val realtime = koin.getOrNull<PayCraftRealtime>() ?: return
542559
applicationScope.launch {
543560
val email = runCatching { koin.getOrNull<PayCraftStore>()?.getEmail() }.getOrNull()
544561
val appUserId = email?.trim()?.lowercase()?.ifBlank { null } ?: deviceId

cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ class PayCraftBillingManager(
138138
_billingState.value = BillingState.Loading
139139
scope.launch {
140140
store.saveEmail(normalized)
141+
PayCraft.refreshRealtimeIdentity() // re-bind entitlement channel to the new email (audit H3)
141142
performRegisterAndLogin(normalized)
142143
}
143144
}
@@ -336,15 +337,19 @@ class PayCraftBillingManager(
336337
val currentState = _billingState.value
337338
PayCraftLogger.onRefreshStatus(email)
338339

339-
// Don't refresh while a conflict/verification/transfer flow is active —
340-
// the concurrent re-register would overwrite OwnershipVerified with DeviceConflict.
341-
if (currentState is BillingState.DeviceConflict ||
342-
currentState is BillingState.OwnershipVerified ||
343-
currentState is BillingState.Loading
344-
) {
340+
// Never refresh over a conflict/verification/transfer flow — a concurrent
341+
// re-register would overwrite OwnershipVerified with DeviceConflict. These
342+
// stay protected even under force.
343+
// A `Loading` window is only skipped when NOT forced: a realtime entitlement
344+
// push (force=true) MUST still land during an in-flight refresh, otherwise the
345+
// live update is silently dropped and falls back to the slow TTL (audit H5).
346+
val protectedFlow = currentState is BillingState.DeviceConflict ||
347+
currentState is BillingState.OwnershipVerified
348+
val loadingSkip = currentState is BillingState.Loading && !force
349+
if (protectedFlow || loadingSkip) {
345350
PayCraftLogger.onFlow(
346351
"refreshStatus",
347-
"SKIPPED — active flow in progress (state=${currentState::class.simpleName})",
352+
"SKIPPED — active flow in progress (state=${currentState::class.simpleName}, force=$force)",
348353
)
349354
return
350355
}
@@ -400,7 +405,7 @@ class PayCraftBillingManager(
400405

401406
val normalized = email.trim().lowercase()
402407
_userEmail.value = normalized
403-
scope.launch { store.saveEmail(normalized) }
408+
scope.launch { store.saveEmail(normalized); PayCraft.refreshRealtimeIdentity() }
404409

405410
// If there's an active conflict and the verified email matches → ownership proven.
406411
val pendingToken = DeviceTokenStore.getToken()
@@ -573,7 +578,7 @@ class PayCraftBillingManager(
573578
_billingState.value = BillingState.Free
574579
lastConflict = null
575580
store.clearCache()
576-
scope.launch { store.clearEmail() }
581+
scope.launch { store.clearEmail(); PayCraft.refreshRealtimeIdentity() } // drop old user's entitlement channel (audit M1)
577582
}
578583

579584
// ─── Store5 entitlement gating (Phase 4 — cache-first + revalidate) ───────

cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/network/PayCraftRealtime.kt

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import kotlinx.coroutines.SupervisorJob
1212
import kotlinx.coroutines.flow.launchIn
1313
import kotlinx.coroutines.flow.onEach
1414
import kotlinx.coroutines.launch
15+
import kotlinx.coroutines.sync.Mutex
16+
import kotlinx.coroutines.sync.withLock
1517
import kotlinx.serialization.json.JsonObject
1618

1719
/**
@@ -42,67 +44,78 @@ class PayCraftRealtime(private val supabase: SupabaseClient) {
4244

4345
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
4446

47+
// All channel state is read/written ONLY inside [mutex] — this makes the
48+
// check-then-subscribe atomic (no duplicate channel when startRealtime is
49+
// called twice per config fetch) and the fields thread-safe across the
50+
// Default-dispatcher coroutines + the calling thread (JVM + Kotlin/Native).
51+
private val mutex = Mutex()
4552
private var configChannel: RealtimeChannel? = null
4653
private var entitlementChannel: RealtimeChannel? = null
4754
private var configTenant: String? = null
4855
private var entitlementKey: String? = null // "tenant:appUserId"
4956

5057
/** Subscribe to `config:{tenantId}`; invoke [onChanged] on every config ping. */
5158
fun ensureConfigChannel(tenantId: String, onChanged: () -> Unit) {
52-
if (configTenant == tenantId && configChannel != null) return
5359
scope.launch {
54-
runCatching {
55-
configChannel?.let { supabase.realtime.removeChannel(it) }
56-
val ch = supabase.channel("config:$tenantId")
57-
ch.broadcastFlow<JsonObject>(event = "config_changed")
58-
.onEach {
59-
PayCraftLogger.onFlow("realtime", "config ping → refetching /config")
60-
onChanged()
61-
}
62-
.launchIn(scope)
63-
ch.subscribe()
64-
configChannel = ch
65-
configTenant = tenantId
66-
PayCraftLogger.onFlow("realtime", "subscribed config:$tenantId")
67-
}.onFailure {
68-
PayCraftLogger.onFlow("realtime", "config subscribe failed (TTL fallback stays): ${it.message}")
60+
mutex.withLock {
61+
if (configTenant == tenantId && configChannel != null) return@withLock
62+
runCatching {
63+
configChannel?.let { supabase.realtime.removeChannel(it) }
64+
val ch = supabase.channel("config:$tenantId")
65+
ch.broadcastFlow<JsonObject>(event = "config_changed")
66+
.onEach {
67+
PayCraftLogger.onFlow("realtime", "config ping → refetching /config")
68+
onChanged()
69+
}
70+
.launchIn(scope)
71+
ch.subscribe()
72+
configChannel = ch
73+
configTenant = tenantId
74+
PayCraftLogger.onFlow("realtime", "subscribed config:$tenantId")
75+
}.onFailure {
76+
PayCraftLogger.onFlow("realtime", "config subscribe failed (TTL fallback stays): ${it.message}")
77+
}
6978
}
7079
}
7180
}
7281

7382
/** Subscribe to `entitlement:{tenantId}:{appUserId}`; re-subscribes on identity change. */
7483
fun ensureEntitlementChannel(tenantId: String, appUserId: String, onChanged: () -> Unit) {
7584
val key = "$tenantId:$appUserId"
76-
if (entitlementKey == key && entitlementChannel != null) return
7785
scope.launch {
78-
runCatching {
79-
entitlementChannel?.let { supabase.realtime.removeChannel(it) }
80-
val ch = supabase.channel("entitlement:$tenantId:$appUserId")
81-
ch.broadcastFlow<JsonObject>(event = "entitlement_changed")
82-
.onEach {
83-
PayCraftLogger.onFlow("realtime", "entitlement ping → force refresh")
84-
onChanged()
85-
}
86-
.launchIn(scope)
87-
ch.subscribe()
88-
entitlementChannel = ch
89-
entitlementKey = key
90-
PayCraftLogger.onFlow("realtime", "subscribed entitlement:$tenantId:***")
91-
}.onFailure {
92-
PayCraftLogger.onFlow("realtime", "entitlement subscribe failed (TTL fallback stays): ${it.message}")
86+
mutex.withLock {
87+
if (entitlementKey == key && entitlementChannel != null) return@withLock
88+
runCatching {
89+
entitlementChannel?.let { supabase.realtime.removeChannel(it) }
90+
val ch = supabase.channel("entitlement:$tenantId:$appUserId")
91+
ch.broadcastFlow<JsonObject>(event = "entitlement_changed")
92+
.onEach {
93+
PayCraftLogger.onFlow("realtime", "entitlement ping → force refresh")
94+
onChanged()
95+
}
96+
.launchIn(scope)
97+
ch.subscribe()
98+
entitlementChannel = ch
99+
entitlementKey = key
100+
PayCraftLogger.onFlow("realtime", "subscribed entitlement:$tenantId:***")
101+
}.onFailure {
102+
PayCraftLogger.onFlow("realtime", "entitlement subscribe failed (TTL fallback stays): ${it.message}")
103+
}
93104
}
94105
}
95106
}
96107

97108
/** Tear down both channels (call on logout / SDK teardown). */
98109
fun stop() {
99110
scope.launch {
100-
configChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
101-
entitlementChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
102-
configChannel = null
103-
entitlementChannel = null
104-
configTenant = null
105-
entitlementKey = null
111+
mutex.withLock {
112+
configChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
113+
entitlementChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
114+
configChannel = null
115+
entitlementChannel = null
116+
configTenant = null
117+
entitlementKey = null
118+
}
106119
}
107120
}
108121
}

0 commit comments

Comments
 (0)