pénzügyi modul továbbfejlesztése (csomagkezelés)
This commit is contained in:
404
plans/logic_spec_subscription_card_upgrade.md
Normal file
404
plans/logic_spec_subscription_card_upgrade.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# 🏗️ 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
|
||||
Reference in New Issue
Block a user