feat(billing): adopt subscription.* + usage.bars WS methods (issue #628) (#629)

* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628)

Add Billing/Plan screen + WS client surface for the backend release audit
(hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream;
subscription.{state,preview,change,resume,upgrade} + usage.bars replace it.

- WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants
- BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys)
- BillingRepository: WS dispatch via HermesWsClient.request + typed decode;
  HermesRpcException (-32601) surfaces as featureUnavailable, not a crash
- BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions
- BillingScreen: HermesScaffold composable (plan card, usage bars, actions)
- NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into
  the INSPECT drawer section

Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.

* fix(billing): align models + UI to live gateway wire shape (issue #628)

Live-probed the 6 subscription.* / usage.bars RPCs against the running
backend (hermes-agent dashboard :9119, 2026-07-19). Findings:

- subscription.state returns logged_in / current / tiers / usage (nested),
  NOT the legacy subscription_type_id / renews_at fields the models assumed.
- Mutating RPCs (preview/change/resume/upgrade) return ok:false *results*
  with error/message/payload (e.g. invalid_token when not portal-authed),
  not RPC-level errors — so the ViewModel's HermesRpcException guard never
  fired and the screen silently half-decoded null fields.
- subscription.upgrade also returns an idempotency_key not in the model.

Changes:
- BillingModels: re-shape SubscriptionStateResponse around current/tiers/
  usage; add ok/error/message/payload envelope + idempotency_key to the
  action responses; add SubscriptionCurrent / SubscriptionTier / preview
  payload types. All nullable + ignoreUnknownKeys (graceful degrade).
- BillingViewModel: detect ok:false results on every call and route to
  featureUnavailable / errorMessage instead of swallowing them.
- BillingScreen: read plan from subscription.current; render a clean
  'no active plan' / 'log in to Nous Portal' card on the logged-out path
  instead of a broken null plan card.
- strings: add billing_no_active_plan / billing_login_required (en + ko).

Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.

* fix(billing): address independent review (agy) — error routing + icon

Triage of the independent AGY review on PR #629:

Fixed (real issues):
- BillingViewModel: usage.bars RPC failure (-32601) no longer marks the
  whole feature unavailable (was blocking the plan card despite the
  'non-fatal' comment); it is now correctly non-fatal.
- BillingViewModel: a failed primary subscription load now surfaces
  errorMessage even when usage bars loaded (was swallowed by
  'sub == null && bars == null').
- BillingScreen: render state.errorMessage as an error card inside
  BillingContent so failed mutating actions (upgrade/cancel) are visible
  instead of silently disappearing.
- BillingScreen: wrap the dead AccountBalanceWallet expression in a real
  Icon() composable in FeatureUnavailableState.

Declined (with rationale, recorded for maintainer):
- Tier-select / upgrade UI (review issue #4): explicitly out of scope per
  the PR body — 'Plan-selection UI ... is not built ... left for a
  follow-up once backend returns the available plans.'
- Rename snake_case Kotlin props to camelCase (review nit #2): incorrect —
  OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case
  wire keys onto snake_case properties; the props are correct. Fixed the
  misleading doc comment instead.

* fix(billing): match REAL logged-in wire shapes + kill null-result crash

The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed
the earlier models assumed the WRONG shape:

- subscription.state.current is {tier_id, tier_name, monthly_credits,
  credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy
  subscription_type_id/status/renews_at fields. Screen rendered 'Free / no
  plan' for a real logged-in user.
- usage.bars is {available, status, plan_name, plan_bar{remaining_display,
  total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>.
- Mutating RPCs return error:'insufficient_scope' when the Portal token
  lacks billing:manage scope (ok:false result, not an RPC error).

Crash fix (root cause of 'Billing RPC returned no result payload'):
- BillingRepository.decode() now returns null on a missing/null result
  instead of requireNotNull() -> IllegalStateException. All repo methods
  return nullable; ViewModel handles null as a non-fatal 'No billing data
  returned' instead of crashing.
- BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the
  resume/cancel branch now gates on can_change_plan (no status field exists
  on current anymore).
- UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added
  billing_credits_remaining / billing_plan / billing_topup strings (en+ko).

Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim
against the captured live responses confirms PlanCard/UsageBarRow/error
paths all resolve with no null-crash.

* fix(billing): decode WS result that arrives as toAny() Map, not JsonElement

Root cause of 'No billing data returned' on a logged-in server: the backend
returns a valid subscription.state result, but HermesWsClient.request()
yields result already converted via JsonElement.toAny() (a Map<String,Any?>),
not a raw JsonElement. BillingRepository.decode() only accepted JsonElement,
so  was null and the repo returned null -> ViewModel showed
the generic 'No billing data returned' message. The server DOES have billing
(logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it.

Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the
toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement
walk, then deserializes. Both shapes decode correctly now.

This also means the previous 'Billing RPC returned no result payload' crash
and the generic 'no data' message were both symptoms of this same parse gap,
not a missing backend feature.

Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-19 15:06:09 +03:00
1 parent 6a421d7a84
commit 00c0ddfb92
9 files changed
+935 -1

No files matched your search

@@ -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
@@ -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,
@@ -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<SubscriptionTier> = 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,
)
@@ -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<String, Any?>`), 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 <reified T> 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<T>(), element)
}
@Suppress("UNCHECKED_CAST")
private fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is JsonElement -> value
is Map<*, *> ->
JsonObject(
(value as Map<String, Any?>).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)
}
}
@@ -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"
}
@@ -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,
)
}
}
@@ -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<BillingUiState> = _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) }
}