diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 6a2f2d6e..9909c802 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -55,6 +55,8 @@ import kotlinx.serialization.Serializable @Serializable data object AnalyticsScreen : NavKey +@Serializable data object BillingScreen : NavKey + // ── Settings drill-down sub-pages ────────────────────────────────────── @Serializable data object SettingsConnection : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index 6e19dd85..ebe687ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.ListAlt +import androidx.compose.material.icons.filled.AccountBalanceWallet import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Bolt @@ -27,6 +28,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.NavKey import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent +import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent @@ -189,6 +191,12 @@ object ScreenRegistry { Icons.Filled.BarChart, DrawerSection.INSPECT, ) { sessionId, openDrawer -> AnalyticsScreenContent(onOpenDrawer = openDrawer) }, + ScreenDefinition( + BillingScreen, + R.string.screen_billing, + Icons.Filled.AccountBalanceWallet, + DrawerSection.INSPECT, + ) { sessionId, openDrawer -> BillingScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( KanbanScreen, R.string.screen_kanban, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/BillingModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/BillingModels.kt new file mode 100644 index 00000000..9d3aeb2f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/BillingModels.kt @@ -0,0 +1,182 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Request/response models for the billing/subscription WebSocket RPC surface + * adopted in issue #628 (backend release audit 0bf44d557..614dc194e). + * + * VERIFIED against the LIVE gateway with a logged-in Nous Portal session + * (probe on :9119, 2026-07-19). Real shapes captured: + * - `subscription.state` -> ok/logged_in/is_admin/can_change_plan/org_name/ + * role/context + `current` {tier_id, tier_name, monthly_credits, + * credits_remaining, cycle_ends_at, pending_downgrade_*} + `tiers`. + * - `usage.bars` -> ok/available/status/plan_name/renews_at + + * `plan_bar` {kind, remaining_display, total_display, spent_display, + * pct_used, fill_fraction} + `topup_bar` (nullable). + * - Mutating RPCs (preview/change/resume/upgrade) -> `ok:false` *result* + * with `error`/`message`/`payload`; when the Portal token lacks the + * `billing:manage` scope they return error:"insufficient_scope". + * + * All models decode with [com.m57.hermescontrol.data.remote.OkHttpProvider.json] + * (kotlinx `Json { ignoreUnknownKeys = true; SnakeCase }`). The `SnakeCase` + * naming strategy maps snake_case wire keys directly onto snake_case Kotlin + * properties, so the property names below mirror the backend JSON exactly and + * unknown backend fields are tolerated. + */ + +@Serializable +data class SubscriptionStateResponse( + val ok: Boolean? = null, + val logged_in: Boolean? = null, + val is_admin: Boolean? = null, + val can_change_plan: Boolean? = null, + val org_name: String? = null, + val org_id: String? = null, + val role: String? = null, + /** "personal" for a direct Nous Portal login, "org" when under an org. */ + val context: String? = null, + /** Active plan summary when [logged_in] is true; null when logged out. */ + val current: SubscriptionCurrent? = null, + /** Available plan tiers the user can switch between (empty when logged out). */ + val tiers: List = emptyList(), + val portal_url: String? = null, + val error: String? = null, +) + +@Serializable +data class SubscriptionCurrent( + val tier_id: String? = null, + val tier_name: String? = null, + /** Monthly credit grant, as a string decimal (e.g. "0.1"). */ + val monthly_credits: String? = null, + /** Credits remaining this cycle, as a string decimal. */ + val credits_remaining: String? = null, + /** ISO timestamp when the current cycle ends. */ + val cycle_ends_at: String? = null, + val pending_downgrade_tier_name: String? = null, + val pending_downgrade_at: String? = null, + val pending_downgrade_display: String? = null, +) + +@Serializable +data class SubscriptionTier( + val tier_id: String? = null, + val tier_name: String? = null, + val price: String? = null, + val currency: String? = null, + val interval: String? = null, +) + +// ── usage.bars ──────────────────────────────────────────────────────────── + +@Serializable +data class UsageBarsResponse( + val ok: Boolean? = null, + val available: Boolean? = null, + /** e.g. "depleted" / "active" — high-level usage status. */ + val status: String? = null, + val plan_name: String? = null, + val renews_at: String? = null, + val renews_display: String? = null, + val subscription_remaining_display: String? = null, + val topup_remaining_display: String? = null, + val total_spendable_display: String? = null, + val has_topup: Boolean? = null, + val plan_bar: UsageBar? = null, + val topup_bar: UsageBar? = null, +) + +@Serializable +data class UsageBar( + val kind: String? = null, + val remaining_display: String? = null, + val total_display: String? = null, + val spent_display: String? = null, + /** 0..100 percentage used (not a fraction). */ + val pct_used: Double? = null, + /** 0.0..1.0 fill fraction for a progress indicator. */ + val fill_fraction: Double? = null, +) + +// ── subscription.preview ────────────────────────────────────────────────── + +@Serializable +data class SubscriptionPreviewRequest( + val subscription_type_id: String, +) + +@Serializable +data class SubscriptionPreviewResponse( + val ok: Boolean? = null, + val error: String? = null, + val message: String? = null, + val payload: SubscriptionPreviewPayload? = null, + val subscription_type_id: String? = null, + val subscription_type_name: String? = null, + val price: String? = null, + val currency: String? = null, + val interval: String? = null, + val proration_credit: String? = null, + val proration_charge: String? = null, + val summary: String? = null, +) + +@Serializable +data class SubscriptionPreviewPayload( + val error: String? = null, + val error_description: String? = null, + val actor: String? = null, + val code: String? = null, + val recovery: String? = null, +) + +// ── subscription.change ─────────────────────────────────────────────────── + +@Serializable +data class SubscriptionChangeRequest( + val subscription_type_id: String? = null, + val cancel: Boolean? = null, +) + +@Serializable +data class SubscriptionChangeResponse( + val ok: Boolean? = null, + val error: String? = null, + val status: String? = null, + val cancel_at_period_end: Boolean? = null, + val message: String? = null, +) + +// ── subscription.resume ─────────────────────────────────────────────────── + +@Serializable +data class SubscriptionResumeResponse( + val ok: Boolean? = null, + val error: String? = null, + val status: String? = null, + val message: String? = null, +) + +// ── subscription.upgrade ────────────────────────────────────────────────── + +@Serializable +data class SubscriptionUpgradeRequest( + val subscription_type_id: String, +) + +@Serializable +data class SubscriptionUpgradeResponse( + val ok: Boolean? = null, + val error: String? = null, + val status: String? = null, + /** Set when the charge needs SCA / customer action (3-D Secure etc.). */ + val requires_action: Boolean? = null, + /** Recovery URL the user must open to complete the action. */ + val recovery_url: String? = null, + /** Set when the charge was declined. */ + val payment_failed: Boolean? = null, + /** Backend-issued idempotency key (present on the live gateway response). */ + val idempotency_key: String? = null, + val message: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt new file mode 100644 index 00000000..ee259080 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt @@ -0,0 +1,117 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.SubscriptionChangeRequest +import com.m57.hermescontrol.data.model.SubscriptionChangeResponse +import com.m57.hermescontrol.data.model.SubscriptionPreviewResponse +import com.m57.hermescontrol.data.model.SubscriptionResumeResponse +import com.m57.hermescontrol.data.model.SubscriptionStateResponse +import com.m57.hermescontrol.data.model.SubscriptionUpgradeResponse +import com.m57.hermescontrol.data.model.UsageBarsResponse +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer + +/** + * Client for the billing/subscription WebSocket RPC surface (issue #628). + * + * Mirrors [HermesWsClient.request]: every call returns the backend `result` + * payload decoded into a typed model, or throws [HermesWsClient.HermesRpcException] + * on an RPC error (e.g. `-32601 unknown method`). + * + * Defensive note: an old cached mobile build may still dispatch the removed + * `credits.view` method and receive `-32601`. We never dispatch it ourselves, + * but [HermesRpcException] from any billing call is surfaced as a clean + * "feature unavailable" state by the ViewModel rather than a crash. + * + * NOTE: [HermesWsClient] hands back `result` already converted via + * `JsonElement.toAny()` (a `Map`), not a raw `JsonElement`. + * [decode] therefore normalizes whatever arrives back to JSON before + * deserializing, so both shapes decode correctly. + */ +object BillingRepository { + private val json get() = OkHttpProvider.json + + @Suppress("UNCHECKED_CAST") + private inline fun decode(result: Any?): T? { + if (result == null) return null + // `result` may arrive as a kotlinx JsonElement OR as a Map produced by + // JsonElement.toAny() in HermesWsClient. Normalize both back to a JSON + // string (via a recursive Any->JsonElement walk) before deserializing. + val element: JsonElement = + when (result) { + is JsonElement -> result + is Map<*, *> -> anyToJsonElement(result) + is List<*> -> JsonArray(result.map { anyToJsonElement(it) }) + else -> JsonPrimitive(result.toString()) + } + return json.decodeFromJsonElement(serializer(), element) + } + + @Suppress("UNCHECKED_CAST") + private fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> JsonNull + is JsonElement -> value + is Map<*, *> -> + JsonObject( + (value as Map).mapValues { (_, v) -> anyToJsonElement(v) }, + ) + is List<*> -> JsonArray(value.map { anyToJsonElement(it) }) + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + else -> JsonPrimitive(value.toString()) + } + + suspend fun getSubscriptionState(): SubscriptionStateResponse? { + val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_STATE).await() + return decode(result) + } + + suspend fun getUsageBars(): UsageBarsResponse? { + val result = HermesWsClient.request(WsMethods.USAGE_BARS).await() + return decode(result) + } + + suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? { + val result = + HermesWsClient + .request( + WsMethods.SUBSCRIPTION_PREVIEW, + mapOf("subscription_type_id" to subscriptionTypeId), + ).await() + return decode(result) + } + + suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? { + val result = + HermesWsClient + .request( + WsMethods.SUBSCRIPTION_CHANGE, + buildMap { + request.subscription_type_id?.let { put("subscription_type_id", it) } + request.cancel?.let { put("cancel", it) } + }, + ).await() + return decode(result) + } + + suspend fun resumeSubscription(): SubscriptionResumeResponse? { + val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_RESUME).await() + return decode(result) + } + + suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? { + val result = + HermesWsClient + .request( + WsMethods.SUBSCRIPTION_UPGRADE, + mapOf("subscription_type_id" to subscriptionTypeId), + ).await() + return decode(result) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 79b1a768..47597720 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -43,4 +43,27 @@ object WsMethods { /** Kill a single background process (scoped to the active session). */ const val PROCESS_KILL = "process.kill" + + // ── Billing / subscription (issue #628) ───────────────────────────── + // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). + // `credits.view` was REMOVED upstream; these replace it. `usage.bars` + // surfaces the same usage data the legacy credits block rendered. + + /** Current subscription/plan state. Fail-open: logged-out → {logged_in:false}. */ + const val SUBSCRIPTION_STATE = "subscription.state" + + /** Chargeless effect quote for a target subscription type. */ + const val SUBSCRIPTION_PREVIEW = "subscription.preview" + + /** Schedule a downgrade or cancellation. */ + const val SUBSCRIPTION_CHANGE = "subscription.change" + + /** Resume a cancelled/paused subscription. */ + const val SUBSCRIPTION_RESUME = "subscription.resume" + + /** Upgrade — hits POST /api/billing/subscription/upgrade (prorate + charge). */ + const val SUBSCRIPTION_UPGRADE = "subscription.upgrade" + + /** Usage bars (token/cost breakdown the legacy credits block rendered). */ + const val USAGE_BARS = "usage.bars" } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingScreen.kt new file mode 100644 index 00000000..7b95cc28 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingScreen.kt @@ -0,0 +1,314 @@ +package com.m57.hermescontrol.ui.billing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalanceWallet +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SubscriptionCurrent +import com.m57.hermescontrol.data.model.SubscriptionStateResponse +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing + +@Composable +fun BillingScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: BillingViewModel = viewModel { BillingViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + if (state.subscription == null && state.usage == null && !state.featureUnavailable) { + viewModel.load() + } + } + + HermesScaffold( + title = { Text(stringResource(R.string.screen_billing)) }, + navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, + isRefreshing = state.isLoading, + onRefresh = { viewModel.load() }, + modifier = modifier, + ) { + when { + state.featureUnavailable -> { + FeatureUnavailableState(onRetry = { viewModel.load() }) + } + + state.isLoading && state.subscription == null && state.usage == null -> { + LoadingState(modifier = Modifier.fillMaxSize()) + } + + state.errorMessage != null && state.subscription == null && state.usage == null -> { + ErrorState( + message = state.errorMessage ?: stringResource(R.string.error_unknown), + onRetry = { viewModel.load() }, + ) + } + + else -> { + BillingContent( + state = state, + onUpgrade = viewModel::upgrade, + onChange = viewModel::change, + onResume = viewModel::resume, + onPreview = viewModel::preview, + ) + } + } + } +} + +@Composable +private fun BillingContent( + state: BillingUiState, + onUpgrade: (String) -> Unit, + onChange: (String?, Boolean?) -> Unit, + onResume: () -> Unit, + onPreview: (String) -> Unit, +) { + val subscription = state.subscription + val usage = state.usage + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + val sub = subscription + if (sub != null && sub.logged_in == true && sub.current != null) { + item { PlanCard(sub.current) } + if (sub.can_change_plan == true) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onChange(null, true) }, + enabled = !state.isActionInFlight, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.billing_cancel)) + } + } + } + } + } else if (sub != null) { + item { NoActivePlanCard(sub) } + } + + if (state.errorMessage != null) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = state.errorMessage ?: "", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + + if (state.actionMessage != null) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = state.actionMessage ?: "", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + if (usage != null && usage.available == true && (usage.plan_bar != null || usage.topup_bar != null)) { + item { SectionTitle(stringResource(R.string.billing_usage)) } + usage.plan_bar?.let { bar -> + item { UsageBarRow(label = stringResource(R.string.billing_plan), bar = bar) } + } + usage.topup_bar?.let { bar -> + item { UsageBarRow(label = stringResource(R.string.billing_topup), bar = bar) } + } + } + } +} + +@Composable +private fun PlanCard(subscription: SubscriptionCurrent) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = subscription.tier_name ?: stringResource(R.string.billing_free_plan), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + if (subscription.credits_remaining != null) { + Text( + text = + stringResource( + R.string.billing_credits_remaining, + subscription.credits_remaining, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + if (subscription.cycle_ends_at != null) { + Text( + text = stringResource(R.string.billing_renews, subscription.cycle_ends_at), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + if (subscription.pending_downgrade_display != null) { + Text( + text = subscription.pending_downgrade_display ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} + +@Composable +private fun NoActivePlanCard(subscription: SubscriptionStateResponse) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.billing_free_plan), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = + if (subscription.logged_in == true) { + stringResource(R.string.billing_no_active_plan) + } else { + stringResource(R.string.billing_login_required) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun UsageBarRow( + label: String, + bar: com.m57.hermescontrol.data.model.UsageBar, +) { + val fraction = (bar.fill_fraction ?: 0.0).toFloat().coerceIn(0f, 1f) + val summary = bar.remaining_display ?: bar.total_display ?: "" + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LinearProgressIndicator( + progress = { fraction }, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) +} + +@Composable +private fun FeatureUnavailableState(onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + androidx.compose.material3.Icon( + imageVector = androidx.compose.material.icons.Icons.Filled.AccountBalanceWallet, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(bottom = 16.dp).size(48.dp), + ) + ErrorState( + message = stringResource(R.string.billing_feature_unavailable), + onRetry = onRetry, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt new file mode 100644 index 00000000..1a701070 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt @@ -0,0 +1,256 @@ +package com.m57.hermescontrol.ui.billing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.SubscriptionChangeRequest +import com.m57.hermescontrol.data.model.SubscriptionStateResponse +import com.m57.hermescontrol.data.model.UsageBarsResponse +import com.m57.hermescontrol.data.ws.BillingRepository +import com.m57.hermescontrol.data.ws.HermesWsClient +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Drives the Billing / Plan screen (issue #628). Pulls plan state + usage bars + * over the WebSocket JSON-RPC surface and exposes upgrade / change / resume / + * cancel actions. + * + * `-32601` (removed `credits.view` or any unknown method) arrives as + * [HermesWsClient.HermesRpcException]; we map it to [BillingUiState.featureUnavailable] + * so an old cached build or a backend mismatch degrades gracefully instead of crashing. + */ +data class BillingUiState( + val isLoading: Boolean = false, + val isActionInFlight: Boolean = false, + val subscription: SubscriptionStateResponse? = null, + val usage: UsageBarsResponse? = null, + val preview: com.m57.hermescontrol.data.model.SubscriptionPreviewResponse? = null, + /** Set when an RPC method is unavailable on the connected backend (e.g. -32601). */ + val featureUnavailable: Boolean = false, + val errorMessage: String? = null, + /** Transient success/info toast after an action (upgrade/resume/cancel). */ + val actionMessage: String? = null, +) + +class BillingViewModel : ViewModel() { + private val _uiState = MutableStateFlow(BillingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var loadJob: Job? = null + + fun load() { + loadJob?.cancel() + _uiState.update { it.copy(isLoading = true, errorMessage = null, featureUnavailable = false) } + loadJob = + viewModelScope.launch { + var sub: SubscriptionStateResponse? = null + var bars: UsageBarsResponse? = null + var unavailable = false + var error: String? = null + try { + sub = BillingRepository.getSubscriptionState() + // The live gateway returns an `ok:false` *result* (with + // `error`/`message`) rather than an RPC-level error when the + // backend feature is missing or the user isn't portal-authed. + // Catch that here so the screen degrades gracefully. + if (sub?.ok == false) { + unavailable = true + error = sub.error ?: "Billing unavailable" + } else if (sub == null) { + // Backend returned no result payload (null result / unmapped + // response). Treat as a non-fatal load miss, not a crash. + if (error == null) error = "No billing data returned" + } + } catch (e: HermesWsClient.HermesRpcException) { + unavailable = true + error = e.message + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + error = e.message ?: "Failed to load subscription" + } + try { + bars = BillingRepository.getUsageBars() + if (bars?.ok == false) { + // usage unavailable is non-fatal — the plan card still renders + if (error == null) error = "Usage data unavailable" + } + } catch (e: HermesWsClient.HermesRpcException) { + // usage unavailable is non-fatal — do NOT mark the whole + // feature unavailable; the plan card still renders. + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + if (error == null) error = e.message ?: "Failed to load usage" + } + _uiState.update { + it.copy( + isLoading = false, + subscription = sub, + usage = bars, + featureUnavailable = unavailable, + // A failed primary subscription load is fatal — surface it + // even if usage bars happened to load. + errorMessage = if (sub == null) error else null, + ) + } + } + } + + fun preview(subscriptionTypeId: String) { + viewModelScope.launch { + _uiState.update { it.copy(isActionInFlight = true, errorMessage = null, actionMessage = null) } + runCatching { BillingRepository.previewSubscription(subscriptionTypeId) } + .onSuccess { preview -> + if (preview == null) { + _uiState.update { + it.copy(isActionInFlight = false, errorMessage = "No billing data returned") + } + return@onSuccess + } + if (preview.ok == false) { + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = preview.error ?: preview.message ?: "Preview unavailable", + ) + } + } else { + _uiState.update { it.copy(isActionInFlight = false, preview = preview) } + } + }.onFailure { e -> + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = (e as? HermesWsClient.HermesRpcException)?.message ?: e.message, + ) + } + } + } + } + + fun upgrade(subscriptionTypeId: String) { + viewModelScope.launch { + _uiState.update { it.copy(isActionInFlight = true, errorMessage = null, actionMessage = null) } + runCatching { BillingRepository.upgradeSubscription(subscriptionTypeId) } + .onSuccess { resp -> + if (resp == null) { + _uiState.update { + it.copy(isActionInFlight = false, errorMessage = "No billing data returned") + } + return@onSuccess + } + if (resp.ok == false) { + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = resp.error ?: resp.message ?: "Upgrade unavailable", + ) + } + return@onSuccess + } + val msg = + when { + resp.requires_action == true && resp.recovery_url != null -> + "Action required — open: ${resp.recovery_url}" + resp.payment_failed == true -> resp.message ?: "Payment failed" + resp.ok == true -> "Upgrade successful" + else -> resp.message ?: "Upgrade requested" + } + _uiState.update { it.copy(isActionInFlight = false, actionMessage = msg) } + load() + }.onFailure { e -> + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = (e as? HermesWsClient.HermesRpcException)?.message ?: e.message, + ) + } + } + } + } + + fun change( + subscriptionTypeId: String? = null, + cancel: Boolean? = null, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isActionInFlight = true, errorMessage = null, actionMessage = null) } + runCatching { BillingRepository.changeSubscription(SubscriptionChangeRequest(subscriptionTypeId, cancel)) } + .onSuccess { resp -> + if (resp == null) { + _uiState.update { + it.copy(isActionInFlight = false, errorMessage = "No billing data returned") + } + return@onSuccess + } + if (resp.ok == false) { + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = resp.error ?: resp.message ?: "Plan change unavailable", + ) + } + return@onSuccess + } + _uiState.update { + it.copy( + isActionInFlight = false, + actionMessage = + resp.message ?: if (cancel == true) "Cancellation scheduled" else "Plan changed", + ) + } + load() + }.onFailure { e -> + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = (e as? HermesWsClient.HermesRpcException)?.message ?: e.message, + ) + } + } + } + } + + fun resume() { + viewModelScope.launch { + _uiState.update { it.copy(isActionInFlight = true, errorMessage = null, actionMessage = null) } + runCatching { BillingRepository.resumeSubscription() } + .onSuccess { resp -> + if (resp == null) { + _uiState.update { + it.copy(isActionInFlight = false, errorMessage = "No billing data returned") + } + return@onSuccess + } + if (resp.ok == false) { + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = resp.error ?: resp.message ?: "Resume unavailable", + ) + } + return@onSuccess + } + _uiState.update { + it.copy( + isActionInFlight = false, + actionMessage = resp.message ?: "Subscription resumed", + ) + } + load() + }.onFailure { e -> + _uiState.update { + it.copy( + isActionInFlight = false, + errorMessage = (e as? HermesWsClient.HermesRpcException)?.message ?: e.message, + ) + } + } + } + } + + fun clearActionMessage() = _uiState.update { it.copy(actionMessage = null) } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ebcb279f..a5ee5a46 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -40,7 +40,22 @@ 검색 지우기 - 알 수 없는 오류 + 문제가 발생했습니다 + 무료 + 사용량 + 재개 + 취소 + 갱신: %1$s + 알 수 없음 + 활성 + 취소됨 + 일시중지됨 + 이 서버에서는 결제를 사용할 수 없습니다 (지원되지 않는 메서드) + 활성 요금제 없음 — 무료 등급입니다. + 요금제를 보려면 Nous Portal에 로그인하세요. + 남은 크레딧: %1$s + 요금제 + 충전 ⚡ 헤르메스 @@ -74,6 +89,7 @@ 프로세스 제공자 분석 + 결제 헤르메스 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2fcc8d7e..6d7b4791 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,6 +54,21 @@ Unknown error + Free + Usage + Resume + Cancel + Renews: %1$s + Unknown + Active + Cancelled + Paused + Billing is unavailable on this server (method not supported) + No active plan — you\'re on the free tier. + Log in to Nous Portal to view your plan. + Credits remaining: %1$s + Plan + Top-up ⚡ Hermes @@ -87,6 +102,7 @@ Processes Providers Analytics + Billing Hermes