Files
service-finder/plans/logic_spec_subscription_card_upgrade.md

405 lines
16 KiB
Markdown

# 🏗️ 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`](frontend_app/src/views/FinanceMainView.vue:118) 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
[`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue:118-154):
```ts
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
1. **`subscription_expires_at` not in `/auth/me` response** — The backend `read_users_me()` already resolves `SubscriptionTier` for `max_vehicles`/`max_garages` but does NOT extract `valid_until` from the `UserSubscription`/`OrganizationSubscription` record.
2. **`subscription_display_name` not in `/auth/me` response** — The `SubscriptionTier.rules["display_name"]` JSONB field exists (e.g., `"display_name": "Privát Pro"`) but is never sent to the frontend via `/auth/me`.
3. **Card 2 doesn't use `vehicleStore`** — The vehicle count is already available in the `SubscriptionStatusWidget.vue` but not in `FinanceMainView.vue`.
---
## 3. 📐 Architecture: Data Flow (Current vs. Target)
```mermaid
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`](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:
```python
# ── 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:
```python
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`](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`):
```typescript
/** 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`](frontend_app/src/views/FinanceMainView.vue)
#### Step 3.1: Import `useVehicleStore`
Add to the imports at line 228:
```typescript
import { useVehicleStore } from '../stores/vehicle'
```
#### Step 3.2: Initialize vehicle store
After line 234:
```typescript
const vehicleStore = useVehicleStore()
```
#### Step 3.3: Replace the computed property
Replace lines 249-252:
```typescript
// ── 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:
```vue
<!--
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:
```typescript
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/hu.ts), [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts)
#### Step 4.1: Add new Hungarian keys
In `hu.ts`, inside the `subscription:` block (after line 87):
```typescript
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):
```typescript
renewNow: 'Renew Now',
upgradePlan: 'Upgrade Plan',
```
---
## 5. 📋 Files Changed Summary
| # | File | Change Type | Lines Affected |
|---|------|-------------|---------------|
| 1 | [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py) | Add `display_name` + `valid_until` resolution | ~+30 lines after L253 |
| 2 | [`frontend_app/src/stores/auth.ts`](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`](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`](frontend_app/src/i18n/hu.ts) | Add `renewNow`, `upgradePlan` keys | ~+2 lines after L87 |
| 5 | [`frontend_app/src/i18n/en.ts`](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.DD` format
- [ ] "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_name` in their tier
- [ ] No regressions on Card 1 (Wallet) and other cards
- [ ] I18n keys work in both `hu` and `en` locales
---
## 8. 📎 References
- [`SubscriptionTier` model](backend/app/models/core_logic.py:68) — `system.subscription_tiers`
- [`UserSubscription` model](backend/app/models/core_logic.py:174) — `finance.user_subscriptions`
- [`OrganizationSubscription` model](backend/app/models/core_logic.py:143) — `finance.org_subscriptions`
- [`SubscriptionService`](backend/app/services/subscription_service.py:74) — Full subscription resolution logic
- [`SubscriptionStatusWidget`](frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue) — Existing rich widget (dark theme)
- [`SubscriptionInfoModal`](frontend_app/src/components/subscription/SubscriptionInfoModal.vue) — Modal with detailed info
- [P0 Subscription JSONB Audit](docs/p0_subscription_jsonb_structural_audit_report.md) — Tier JSONB structure verified