* 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:
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) }
|
||||
}
|
||||
Reference in new issue
Block a user