fix(billing): align SubscriptionCurrent + SubscriptionTier to backend wire (issue #631) (#632)

Verified against backend source at tip 299e409f15 (tui_gateway/server.py).

- SubscriptionCurrent: add cancel_at_period_end / cancellation_effective_at / cancellation_effective_display (emitted by _serialize_subscription_state, were missing on mobile).

- SubscriptionTier: align to wire shape {tier_id, name, tier_order, dollars_per_month_display, monthly_credits, is_current, is_enabled}; old {tier_name, price, currency, interval} never matched the backend and is unused in-app.

- NOTE: usage.bars UsageBar *_display string model is CORRECT — the backend serializes raw *_usd floats to *_display strings via _serialize_usage_bar before sending. Issue #631 misidentified this as a gap; switching to *_usd would break the bars. No change made there.

Co-authored-by: ronia <ronia@m57>
This commit is contained in:
M57
2026-07-19 22:25:42 +03:00
committed by GitHub
co-authored by ronia
parent 3363c1e586
commit d839c5e9ad
@@ -7,13 +7,22 @@ import kotlinx.serialization.Serializable
* adopted in issue #628 (backend release audit 0bf44d557..614dc194e). * adopted in issue #628 (backend release audit 0bf44d557..614dc194e).
* *
* VERIFIED against the LIVE gateway with a logged-in Nous Portal session * VERIFIED against the LIVE gateway with a logged-in Nous Portal session
* (probe on :9119, 2026-07-19). Real shapes captured: * (probe on :9119, 2026-07-19) AND cross-checked against backend source at
* - `subscription.state` -> ok/logged_in/is_admin/can_change_plan/org_name/ * tip 299e409f15 (tui_gateway/server.py _serialize_usage_model /
* role/context + `current` {tier_id, tier_name, monthly_credits, * _serialize_usage_bar / _serialize_subscription_state). Real wire shapes:
* credits_remaining, cycle_ends_at, pending_downgrade_*} + `tiers`. * - `subscription.state.current` -> {tier_id, tier_name, monthly_credits,
* credits_remaining, cycle_ends_at, pending_downgrade_*,
* cancel_at_period_end, cancellation_effective_at,
* cancellation_effective_display}.
* - `subscription.state.tiers[]` -> {tier_id, name, tier_order,
* dollars_per_month_display, monthly_credits, is_current, is_enabled}.
* - `usage.bars` -> ok/available/status/plan_name/renews_at + * - `usage.bars` -> ok/available/status/plan_name/renews_at +
* `plan_bar` {kind, remaining_display, total_display, spent_display, * `plan_bar` {kind, remaining_display, total_display, spent_display,
* pct_used, fill_fraction} + `topup_bar` (nullable). * pct_used, fill_fraction} + `topup_bar` (nullable) + top-level
* `*_remaining_display` / `total_spendable_display` USD strings.
* NOTE: the backend `UsageBar` *dataclass* carries raw `*_usd` floats, but
* `_serialize_usage_bar` converts them to `*_display` strings on the wire —
* so the `*_display` model below is correct; do NOT switch it to `*_usd`.
* - Mutating RPCs (preview/change/resume/upgrade) -> `ok:false` *result* * - Mutating RPCs (preview/change/resume/upgrade) -> `ok:false` *result*
* with `error`/`message`/`payload`; when the Portal token lacks the * with `error`/`message`/`payload`; when the Portal token lacks the
* `billing:manage` scope they return error:"insufficient_scope". * `billing:manage` scope they return error:"insufficient_scope".
@@ -57,15 +66,30 @@ data class SubscriptionCurrent(
val pending_downgrade_tier_name: String? = null, val pending_downgrade_tier_name: String? = null,
val pending_downgrade_at: String? = null, val pending_downgrade_at: String? = null,
val pending_downgrade_display: String? = null, val pending_downgrade_display: String? = null,
/** True when a cancellation is scheduled at the end of the period. */
val cancel_at_period_end: Boolean? = null,
/** ISO timestamp when the scheduled cancellation takes effect. */
val cancellation_effective_at: String? = null,
/** Human-formatted effective date (e.g. "Jul 24, 2026"), or null. */
val cancellation_effective_display: String? = null,
) )
@Serializable @Serializable
data class SubscriptionTier( data class SubscriptionTier(
val tier_id: String? = null, val tier_id: String? = null,
val tier_name: String? = null, /** Display name (wire key is `name`, not `tier_name`). */
val price: String? = null, val name: String? = null,
val currency: String? = null, /** Sort order within the catalog. Backend may serialize this as a float
val interval: String? = null, * (e.g. `5.0`) on some NAS responses, so model as Double, not Int. */
val tier_order: Double? = null,
/** Pre-formatted monthly price, e.g. "$20" / "$20.00". */
val dollars_per_month_display: String? = null,
/** Monthly credit grant as a string decimal (e.g. "0.1"). */
val monthly_credits: String? = null,
/** True when this tier is the user's current plan. */
val is_current: Boolean? = null,
/** True when the tier is selectable. */
val is_enabled: Boolean? = null,
) )
// ── usage.bars ──────────────────────────────────────────────────────────── // ── usage.bars ────────────────────────────────────────────────────────────