16 KiB
🏗️ Logic Spec: Subscription Card Upgrade (FinanceMainView)
Date: 2026-07-27
Author: System Architect (Architect Mode)
Status: 🔵 Blueprint — Ready for Code Mode Hand-off
Masterbook Ref: Master Book 2.0 / Epic 3: Financial Motor / Subscription Packages
1. 🎯 Objective
Transform the "📦 Csomagok" card (Card 2) in FinanceMainView.vue from a dead display of the raw DB slug (private_pro_v1) into a highly functional dashboard widget showing:
| # | Feature | Priority |
|---|---|---|
| 1 | Human-readable display name (e.g., "Privát Pro") | P0 |
| 2 | Expiry/renewal date (e.g., "Érvényes: 2026.12.31-ig") | P0 |
| 3 | Vehicle usage capacity (e.g., "Kezelt járművek: 3 / 5") | P1 |
| 4 | Dynamic CTA button ("Csomag kezelése" / "Upgrade") | P1 |
2. 🔍 Gap Analysis
2.1 Current State — What Card 2 Shows Today
const subscriptionPlanName = computed(() => {
return authStore.user?.subscription_plan || '—'
})
This displays the raw DB slug like private_pro_v1 with no formatting.
2.2 What Data IS Available
| Data Point | Source | Available in authStore? | Available via API? |
|---|---|---|---|
subscription_plan (slug) |
identity.users.subscription_plan |
✅ | /auth/me |
max_vehicles |
Resolved from SubscriptionTier.rules.allowances |
✅ | /auth/me (line 259) |
max_garages |
Resolved from SubscriptionTier.rules.allowances |
✅ | /auth/me (line 260) |
display_name |
SubscriptionTier.rules.display_name JSONB |
❌ | Only via GET /subscriptions/my |
subscription_expires_at |
UserSubscription.valid_until |
❌ NOT in /auth/me |
GET /subscriptions/my |
vehicleCount |
vehicleStore.vehicles.length |
N/A (vehicleStore) | N/A |
subscription_valid_until |
Org-level: OrganizationSubscription.valid_until |
Partially (org data) | GET /organizations/my |
2.3 Key Gaps to Fill
-
subscription_expires_atnot in/auth/meresponse — The backendread_users_me()already resolvesSubscriptionTierformax_vehicles/max_garagesbut does NOT extractvalid_untilfrom theUserSubscription/OrganizationSubscriptionrecord. -
subscription_display_namenot in/auth/meresponse — TheSubscriptionTier.rules["display_name"]JSONB field exists (e.g.,"display_name": "Privát Pro") but is never sent to the frontend via/auth/me. -
Card 2 doesn't use
vehicleStore— The vehicle count is already available in theSubscriptionStatusWidget.vuebut not inFinanceMainView.vue.
3. 📐 Architecture: Data Flow (Current vs. Target)
graph TD
subgraph "Current Flow (BROKEN)"
A1["/auth/me"] -->|subscription_plan slug| B1[authStore.user]
B1 -->|raw slug 'private_pro_v1'| C1[FinanceMainView Card 2]
end
subgraph "Target Flow (UPGRADED)"
A2["/auth/me"] -->|+ display_name + expires_at + max_vehicles| B2[authStore.user]
A2 -->|+ subscription_tier_id| B2
B2 -->|display_name 'Privát Pro'| C2[FinanceMainView Card 2]
B2 -->|expires_at '2026-12-31'| C2
B2 -->|max_vehicles 5| C2
D[vehicleStore.vehicles.length] -->|vehicleCount 3| C2
end
4. 🛠️ Implementation Blueprint
PHASE 1: Backend — Enrich /auth/me Response
File: backend/app/api/v1/endpoints/users.py
Step 1.1: Resolve subscription_expires_at and subscription_display_name
In the read_users_me() function, after the P0 block at lines 206-253 (where max_vehicles and max_garages are resolved), add new resolution logic:
Location: After line 253 (max_garages = int(allowances.get("max_garages", 1))), add:
# ── P0 Subscription Card Upgrade: resolve display_name and valid_until ──
subscription_display_name = None
subscription_valid_until = None
subscription_tier_id = None
if active_org_id is not None:
# Org-level: get valid_until from OrganizationSubscription
org_sub_full_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
org_sub_full = (await db.execute(org_sub_full_stmt)).scalar_one_or_none()
if org_sub_full:
subscription_valid_until = org_sub_full.valid_until
subscription_tier_id = org_sub_full.tier_id
else:
# Personal mode: get valid_until from UserSubscription
user_sub_full_stmt = (
select(UserSubscription)
.where(
UserSubscription.user_id == current_user.id,
UserSubscription.is_active == True
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
user_sub_full = (await db.execute(user_sub_full_stmt)).scalar_one_or_none()
if user_sub_full:
subscription_valid_until = user_sub_full.valid_until
subscription_tier_id = user_sub_full.tier_id
# Resolve display_name from the already-fetched tier (reuse from lines 211-253)
if tier and tier.rules:
subscription_display_name = tier.rules.get("display_name", None)
Step 1.2: Add new fields to the response dict
After line 260 (response_data["max_garages"] = max_garages), add:
response_data["subscription_expires_at"] = (
subscription_valid_until.isoformat() if subscription_valid_until else None
)
response_data["subscription_display_name"] = subscription_display_name
response_data["subscription_tier_id"] = subscription_tier_id
Note: The _build_user_response() helper at line 121-144 defines the base shape. We do NOT need to modify it — the new fields are injected after _build_user_response() returns, just like max_vehicles and max_garages already are.
PHASE 2: Frontend — Update Type Definitions
File: frontend_app/src/stores/auth.ts
Step 2.1: Extend UserProfile interface
Add two new fields to the UserProfile interface (after line 66 max_garages):
/** P0 Subscription Card Upgrade: Human-readable plan name (e.g., "Privát Pro") */
subscription_display_name?: string | null
/** P0 Subscription Card Upgrade: FK to system.subscription_tiers */
subscription_tier_id?: number | null
The subscription_expires_at field already exists at line 63. ✅
PHASE 3: Frontend — Rewrite Card 2 in FinanceMainView
File: frontend_app/src/views/FinanceMainView.vue
Step 3.1: Import useVehicleStore
Add to the imports at line 228:
import { useVehicleStore } from '../stores/vehicle'
Step 3.2: Initialize vehicle store
After line 234:
const vehicleStore = useVehicleStore()
Step 3.3: Replace the computed property
Replace lines 249-252:
// ── Card 2: Subscription data (upgraded) ──────────────────────────
const subscriptionDisplayName = computed(() => {
// Priority: 1) display_name from backend, 2) humanized slug, 3) fallback
const displayName = authStore.user?.subscription_display_name
if (displayName) return displayName
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
// Simple slug→human mapping as fallback
const slugMap: Record<string, string> = {
'private_pro_v1': 'Privát Pro',
'corp_premium_v1': 'Céges Prémium',
'corp_premium_plus_v1': 'Céges Prémium Plus',
}
return slugMap[plan] || plan
})
const subscriptionExpiresAt = computed(() => {
return authStore.user?.subscription_expires_at ?? null
})
const formattedExpiry = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
})
const isExpired = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return false
return new Date(raw) < new Date()
})
const vehicleCount = computed(() => vehicleStore.vehicles?.length || 0)
const maxVehicles = computed(() => authStore.user?.max_vehicles ?? 0)
const vehiclePercent = computed(() => {
if (!maxVehicles.value || maxVehicles.value <= 0) return 0
return Math.min(100, (vehicleCount.value / maxVehicles.value) * 100)
})
// CTA routing depends on mode
const ctaRoute = computed(() => {
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
return `/organization/${authStore.user.active_organization_id}/subscription`
}
return '/dashboard/subscription'
})
const ctaLabel = computed(() => {
if (isExpired.value) return t('subscription.renewNow') || 'Újítás most'
return t('subscription.managePlan')
})
Step 3.4: Replace Card 2 template (lines 118-154)
Replace the entire Card 2 block with:
<!-- ═══════════════════════════════════════════════════════════════
Card 2: 📦 Csomagok (Packages) — UPGRADED
Shows display_name, expiry date, vehicle usage, dynamic CTA.
═══════════════════════════════════════════════════════════════ -->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
<span
:class="isExpired ? 'bg-red-500/30 text-red-200' : 'bg-emerald-500/30 text-emerald-200'"
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
>
{{ isExpired ? t('subscription.expired') : t('subscription.active') }}
</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Plan name -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
{{ t('subscription.active') }}
</p>
<p class="text-2xl font-extrabold text-sky-600">
{{ subscriptionDisplayName }}
</p>
</div>
<!-- Expiry date -->
<div v-if="formattedExpiry" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
<p class="text-xs text-slate-500">
{{ t('subscription.expiresAt') }}:
<span
:class="isExpired ? 'text-red-600 font-bold' : 'text-slate-700 font-semibold'"
>{{ formattedExpiry }}</span>
</p>
</div>
<!-- Vehicle usage -->
<div v-if="maxVehicles > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs text-slate-500">
<span>🚗 {{ t('subscription.vehicles') }}</span>
<span class="font-semibold text-slate-700">{{ vehicleCount }} / {{ maxVehicles }}</span>
</div>
<div class="w-full h-1.5 rounded-full bg-slate-200 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="vehiclePercent >= 90 ? 'bg-red-500' : vehiclePercent >= 75 ? 'bg-amber-400' : 'bg-sky-500'"
:style="{ width: `${Math.min(vehiclePercent, 100)}%` }"
/>
</div>
</div>
</div>
<!-- CTA Button -->
<div class="shrink-0 pt-2">
<router-link
:to="ctaRoute"
class="block w-full text-center px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 shadow-md hover:shadow-lg active:scale-[0.98]"
:class="isExpired
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white hover:from-amber-400 hover:to-orange-500'
: 'bg-gradient-to-r from-sky-600 to-indigo-600 text-white hover:from-sky-500 hover:to-indigo-500'"
>
{{ ctaLabel }}
</router-link>
</div>
</div>
</div>
Step 3.5: Add onMounted vehicle fetch
In the existing onMounted block at line 263-265, add vehicle store fetch:
onMounted(() => {
financeStore.fetchWalletBalance()
// Ensure vehicles are loaded for usage display
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
})
PHASE 4: Frontend — I18n Keys
Files: frontend_app/src/i18n/hu.ts, frontend_app/src/i18n/en.ts
Step 4.1: Add new Hungarian keys
In hu.ts, inside the subscription: block (after line 87):
renewNow: 'Újítás most',
upgradePlan: 'Csomag váltása',
Step 4.2: Add new English keys
In en.ts, inside the subscription: block (at corresponding position):
renewNow: 'Renew Now',
upgradePlan: 'Upgrade Plan',
5. 📋 Files Changed Summary
| # | File | Change Type | Lines Affected |
|---|---|---|---|
| 1 | backend/app/api/v1/endpoints/users.py |
Add display_name + valid_until resolution |
~+30 lines after L253 |
| 2 | frontend_app/src/stores/auth.ts |
Add subscription_display_name, subscription_tier_id to UserProfile |
~+3 lines after L66 |
| 3 | frontend_app/src/views/FinanceMainView.vue |
Rewrite Card 2 template + computed properties | Replace L118-154, add computed after L252 |
| 4 | frontend_app/src/i18n/hu.ts |
Add renewNow, upgradePlan keys |
~+2 lines after L87 |
| 5 | frontend_app/src/i18n/en.ts |
Add renewNow, upgradePlan keys |
~+2 lines at matching position |
6. ⚠️ Risk Assessment
| Risk | Mitigation |
|---|---|
display_name may be null in old tier records |
Fallback chain: display_name → slug→human map → "—" |
valid_until may be None for lifetime subscriptions |
Conditionally render expiry block (v-if="formattedExpiry") |
vehicleStore.vehicles might not be loaded yet |
onMounted fetches vehicles if needed |
| Corporate vs. Individual mode routing | ctaRoute computed handles both modes |
7. 🧪 Testing Checklist
- Card 2 shows
display_name(e.g., "Privát Pro") instead of raw slug - Expiry date displayed as
YYYY.MM.DDformat - "Lejárt" badge shown when subscription is expired
- Vehicle usage bar shows correct
vehicleCount / maxVehicles - CTA button navigates to correct route (personal or org subscription page)
- CTA label changes to "Újítás most" when expired
- Fallback works for users without
display_namein their tier - No regressions on Card 1 (Wallet) and other cards
- I18n keys work in both
huandenlocales
8. 📎 References
SubscriptionTiermodel —system.subscription_tiersUserSubscriptionmodel —finance.user_subscriptionsOrganizationSubscriptionmodel —finance.org_subscriptionsSubscriptionService— Full subscription resolution logicSubscriptionStatusWidget— Existing rich widget (dark theme)SubscriptionInfoModal— Modal with detailed info- P0 Subscription JSONB Audit — Tier JSONB structure verified