Files
service-finder/plans/logic_spec_subscription_card_upgrade.md

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

FinanceMainView.vue:

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)

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.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