pénzügyi modul továbbfejlesztése (csomagkezelés)

This commit is contained in:
Roo
2026-07-29 09:46:10 +00:00
parent 6f28d3e70d
commit 75904cd2f8
50 changed files with 5851 additions and 312 deletions

View File

@@ -47,3 +47,317 @@ A `POST /api/v1/expenses/` végpont 500-as hibát dobott insurance (biztosítás
### Documentation ### Documentation
`docs/p0_expense_500_import_bugfix_2026-07-26.md` `docs/p0_expense_500_import_bugfix_2026-07-26.md`
**Gitea Issue:** #421 (closed) **Gitea Issue:** #421 (closed)
## 2026-07-27 — Subscription Card Upgrade (Gitea #423)
### Changes Made
**Scope:** FinanceMainView Card 2 upgrade — from raw slug display to functional widget.
**Backend (1 file):**
- [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py) — Extended `read_users_me()` to resolve and return 3 new fields: `subscription_display_name` (from `SubscriptionTier.rules.display_name`), `subscription_expires_at` (from `UserSubscription`/`OrganizationSubscription.valid_until`), and `subscription_tier_id` (FK to `system.subscription_tiers`).
**Frontend (4 files):**
- [`frontend_app/src/stores/auth.ts`](frontend_app/src/stores/auth.ts) — Added `subscription_display_name` and `subscription_tier_id` fields to `UserProfile` interface.
- [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) — Rewrote Card 2 with: human-readable display name (fallback chain), formatted expiry date (`YYYY.MM.DD`), vehicle usage progress bar (via `vehicleStore`), and dynamic CTA button ("Csomag Kezelése" / "Újítás most").
- [`frontend_app/src/i18n/hu.ts`](frontend_app/src/i18n/hu.ts) — Added `renewNow` and `upgradePlan` keys.
- [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts) — Added `renewNow` and `upgradePlan` keys.
### Verification
-`sync_engine` — 1307 items OK, 0 errors
-`/auth/me` returns new fields correctly (`display_name: None` for admin without subscription, `"Céges Prémium"` for org with subscription)
- ✅ Syntax checked, AST parse OK
---
## Gitea #424: Finance Flip Card + Context-Aware Filter + Admin Memory Fix
**Dátum:** 2026-07-27
### Changes
- [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py) — Added `created_at` to `_build_user_response()`, `subscription_valid_from` init/extraction, active add-ons query block, and response fields.
- [`frontend_app/src/stores/auth.ts`](frontend_app/src/stores/auth.ts) — Added `user_registration_date`, `subscription_valid_from`, `active_addons` to `UserProfile` interface.
- [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) — Replaced Card 2 with 3D flip card (CSS perspective/rotateY), including `ref` import, `isFlipped` state, computed properties for back face data, and helper functions for add-on dates.
- [`frontend_app/src/views/SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue) — Enhanced `filteredPlans` to check `rules.type` JSONB before name prefix fallback.
- [`frontend_app/src/i18n/hu.ts`](frontend_app/src/i18n/hu.ts) — Added 7 flip card i18n keys.
- [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts) — Added 7 flip card i18n keys.
- [`docker-compose.yml`](docker-compose.yml) — NODE_OPTIONS already configured; purged `.nuxt/` cache and restarted `sf_admin_frontend`.
### Verification
-`sync_engine` — 1307 items OK, 0 Fixed, 0 Shadow — perfectly in sync
- ✅ Backend changes compile without syntax errors
-`.nuxt` cache purged, admin container restarted
---
## Gitea #426: Admin Packages Crash Fix + Phase E Auto-Renewal UI
**Dátum:** 2026-07-28
### Probléma
Az admin Packages (Tiers) nézet betöltésekor Pydantic validation crash történt, mert:
1. **Legacy `rules.type = "base"`** — A `SubscriptionRulesModel` `type` mezőjének `pattern=r"^(private|corporate|addon)$"` regex-e elutasította a "base" értéket
2. **Üres `pricing: {}`** — A `PricingModel` `monthly_price` mezője kötelező, de legacy adatoknál előfordul üres dict
3. **Hiányzó/üres JSONB blokkok**`lifecycle: {}`, `allowances: {}`, `marketing: {}` mind hibát okoztak
### Javítás (2 fájl)
**Fix 1 — Pydantic Legacy Data Sanitizer:** [`subscription.py`](dev/service_finder/backend/app/schemas/subscription.py:158-227)
- Hozzáadva `model_validator(mode="before")` a `SubscriptionRulesModel`-hez
- Automatikusan kezeli: üres dict-ek → None, `type: "base"``"private"`, hiányzó mezők → alapértékek
- Kiterjesztve: pricing_zones, renewal, allowances sanitization
**Fix 2 — Phase E Admin UI:** [`packages/index.vue`](dev/service_finder/frontend_admin/pages/packages/index.vue)
- Új "🔄 Auto-Renewal" tab a modalban (4. tab)
- Toggle: `renewal_enabled`, `auto_renew_default`, `retry_on_failure`
- Input: `renewal_period_days`, `grace_period_days`, `max_retry_count`
- Form state, loading (openEditModal), és save payload (rulesPayload) mind kiterjesztve
- Renewal mező hozzáadva a `SubscriptionTier` és `PackageForm` interfészekhez
### Verifikáció
- ✅ Backend: `from app.schemas.subscription import SubscriptionRulesModel` — OK
- ✅ 4 Pydantic teszt: legacy empty dict, missing renewal, modern renewal, partial renewal — ALL PASSED
-`from app.api.v1.endpoints.admin_packages import ...` — OK
-`sync_engine` — 1319 items OK, 0 Fixed, 0 Shadow — perfectly in sync
---
## Gitea #427: Regression Fix — Header Label + Non-public Package Visibility
**Datum:** 2026-07-28
### Regression 1 - Header Label (Frontend)
**File:** [`HeaderCompanySwitcher.vue:199-200`](dev/service_finder/frontend_app/src/components/header/HeaderCompanySwitcher.vue:199)
**Root Cause:** After Fix 3 of #425, the fallback for individual orgs used `t('header.garageSelector')` which translates to "Garage Selector".
**Fix:** Changed to `t('header.privateGarage')` which correctly shows "Private Garage" / "Privat garazs".
### Regression 2 - Non-public Package Visibility (Backend)
**File:** [`subscriptions.py:165-173`](dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py:165)
**Root Cause:** The GET /subscriptions/public SQL query had NO WHERE clause - all tiers fetched. Python-level filter defaulted is_public to True when lifecycle was missing.
**Fix:** Added SQL-level WHERE clause using `~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])` (same as admin_packages.py:134).
### Verification
- Backend import: from app.api.v1.endpoints import subscriptions - OK
- sync_engine: 1319 items OK, 0 Fixed, 0 Shadow
- Gitea #427: created, started
---
## Gitea #428: Three Bug Fixes - Admin Login Loop, Purchase Simulation, Missing i18n
**Datum:** 2026-07-28
### Fix 1 - Admin Login Infinite Loop (P0)
**File:** auth.ts:266-272
**Root Cause:** After login, login() checks isKycComplete and redirects. Admins lack KYC data, causing redirect loop.
**Fix:** Added staff role check to skip KYC redirect for staff users.
### Fix 2 - Purchase Simulation to Real API
**File:** SubscriptionPlansView.vue:295-325
**Root Cause:** handleOrder() was a console.log simulation stub.
**Fix:** Now calls POST /financial-manager/purchase-package with tier_id, org_id, region_code, currency. Refreshes user data on success.
### Fix 3 - Missing i18n Key
**Files:** hu.ts:74, en.ts:74
**Root Cause:** PlanDetailsModal.vue:140 referenced t(subscription.features.affiliateCommission) but key was missing.
**Fix:** Added affiliateCommission to both locale files.
### Verification
- Backend import: financial_manager.purchase_package - OK
- sync_engine: 1319 items OK
- Gitea #428: created, started, finished
---
## P0: Dashboard Infinite Mount/Unmount Loop Fix (8th Attempt)
**Dátum:** 2026-07-28
**Authored by:** Architect (root cause) → Code (implementation)
### Probléma
A Dashboard csempék (5-card grid) végtelen mount/unmount ciklusba kerültek `admin@profibot.hu` standard admin felhasználónál. A gridet a `vehicleStore.isLoading` állapot szabályozza `v-if="!vehicleStore.isLoading"` guard-dal. Minden újramountoláskor a gyermek widgetek újraélesztették a reaktív kaszkádot.
### Gyökér-ok (3 rétegű kaszkád)
1. **`authStore.fetchUser()` dedup védelem nélkül** — Két call-site is hívta: `App.vue.onMounted()` + `router.beforeEach()`. Az `init()` guard (`isInitialized`) már későn jött: a második hívás `fetchUser()`-t indított, ami `user.value`-t duplán állította → reaktív kaszkád minden computed property-ben.
2. **`authStore.init()` szintén dedup nélkül** — A `beforeEach` await-eli, az `App.vue` nem, így race condition volt.
3. **`DashboardView.onMounted()` minden alkalommal hívta `fetchMyOrganizations()`-t** — Még akkor is, ha az `init()` már betöltötte. Ez új API hívást → új referencia objektumokat → reaktív újraszámolást okozott.
### Miért csak a standard admin? — A superadmin-nál egyszerűbb a szervezeti struktúra, kevesebb `find()` a computed-ekben, és a referencia egyezés miatt a Vue kihagyta az újrarenderelést.
### Javítások (3 fájlban)
| # | Fájl | Módosítás |
|---|------|-----------|
| 1 | [`stores/auth.ts:296-318`](dev/service_finder/frontend_app/src/stores/auth.ts:296) | `_userFetchInProgress` dedup guard a `fetchUser()`-ben |
| 2 | [`stores/auth.ts:443-463`](dev/service_finder/frontend_app/src/stores/auth.ts:443) | `_initInProgress` + `isInitialized` guard az `init()`-ben |
| 3 | [`views/DashboardView.vue:365-388`](dev/service_finder/frontend_app/src/views/DashboardView.vue:365) | `fetchMyOrganizations()` csak ha `length === 0` |
### Dokumentáció
`docs/dashboard_infinite_loop_root_cause_v8.md`
---
## P0: Dashboard Infinite Loop — Structural Fix (vIf Removal + Local Loading)
**Dátum:** 2026-07-28 (2nd pass)
**Típus:** Strukturális Vue lifecycle javítás (band-aid → architectural fix)
### Probléma
Az előző javítás (dedup guard-ok) csak tüneti kezelés volt. A `v-if="!vehicleStore.isLoading"` guard továbbra is a grid létét kötötte egy globális loading állapothoz. Ha bármikor `isLoading` `true`-ra vált (pl. manuális refresh), a teljes grid elpusztul és újraépül → potenciális loop, még ha a dedup guard-ok ritkítják is.
### Gyökér-ok
A [`DashboardView.vue`](dev/service_finder/frontend_app/src/views/DashboardView.vue:42-53) mindkét `v-if` guard-ban használta a `vehicleStore.isLoading`-ot:
- `v-if="vehicleStore.isLoading"` — spinner
- `v-if="!vehicleStore.isLoading"` — grid
Ez azt jelenti, hogy a grid DOM-léte a store globális állapotához volt kötve.
### Javítások (3 fájlban)
| # | Fájl | Módosítás |
|---|------|-----------|
| 1 | [`views/DashboardView.vue:41-55`](dev/service_finder/frontend_app/src/views/DashboardView.vue:41) | **ELTÁVOLÍTVA** mindkét globális `v-if` guard (spinner + grid). A grid most mindig mountolva marad. |
| 2 | [`components/dashboard/MyVehiclesCard.vue`](dev/service_finder/frontend_app/src/components/dashboard/MyVehiclesCard.vue) | **HOZZÁADVA** lokális loading spinner: `v-if="vehicleStore.isLoading && vehicles.length === 0"` — mutatja a spinnert, amíg nincs adat, és csak utána az empty state-t. |
| 3 | [`components/dashboard/CostsActionsCard.vue`](dev/service_finder/frontend_app/src/components/dashboard/CostsActionsCard.vue) | **HOZZÁADVA** lokális loading spinner + a "No vehicle selected" warning csak akkor jelenik meg, ha az adatok már betöltődtek, de tényleg nincs jármű. |
### Widget audit — ki hogyan kezeli a loading-ot:
| Widget | Státusz |
|--------|---------|
| MyVehiclesCard | ✅ Lokális spinner (most) |
| CostsActionsCard | ✅ Lokális spinner (most) |
| ServiceFinderCard | 🟢 Statikus UI, nincs adatfüggőség |
| GamificationCard | 🟢 Helyi ref-ek, 0 default |
| ProfileTrustCard | 🟢 financeStore default 0, elfogadható |
| SubscriptionStatusWidget | 🟢 Saját `isLoading` ref + spinner (korábban) |
| AdPlacementWidget | 🟢 Saját `isLoading` ref + spinner (korábban) |
### Megjegyzés
A dedup guard-ok (`_userFetchInProgress`, `_initInProgress`, feltételes `fetchMyOrganizations`) megtartva — jó, ha megvannak mint extra biztonsági réteg.
---
## Phase 2: Mock Payment Gateway Simulation (Gitea #430)
**Dátum:** 2026-07-28
### Implementált módosítások
1. **`backend/app/services/mock_payment_gateway.py`** — Új `simulate_redirect` mód alapértelmezettként. `create_intent()` amount > 0 esetén `checkout_url`-t és `requires_action` státuszt ad vissza. Logolja a `[MOCK_PAYMENT_REQUEST]` üzenetet.
2. **`backend/app/services/financial_manager.py`** — `PurchaseResult` DTO bővítve `checkout_url` és `payment_status` mezőkkel. A paid-tier flow kezeli a `requires_action` választ: metadata tárolás a PaymentIntent-en, subscription aktiválás deferálása, checkout_url visszaadása a frontendnek.
3. **`backend/app/schemas/financial_manager.py`** — `PurchaseResponse` Pydantic séma bővítve `checkout_url: Optional[str]` és `payment_status: Optional[str]` mezőkkel.
4. **`backend/app/api/v1/endpoints/billing.py`** — Két új végpont:
- `GET /mock-payment/checkout/{intent_id}` — Styled HTML fizetési oldal "Pay Now" gombbal
- `POST /mock-payment/callback` — Webhook callback: PaymentIntent COMPLETED-re állítása, subscription aktiválása, redirect a frontendre
5. **`backend/app/api/v1/endpoints/financial_manager.py`** — `get_financial_manager()` dependency most `simulate_redirect` módot használ.
### Verifikáció
- ✅ AST syntax check: minden fájl OK
- ✅ Docker containerben minden import sikeres
- ✅ Mock checkout + callback route-ok regisztrálva (`/billing/mock-payment/checkout/`, `/billing/mock-payment/callback`)
- ✅ Sync Engine: 1323 elem, tökéletes szinkron
---
## Fix: Subscription Limit Data Binding Bug (Gitea #431)
**Dátum:** 2026-07-28
### Probléma
A frontenden a SubscriptionStatusWidget és a FinanceMainView Card 2 (Flip Card) rossz limitértékeket mutatott: `max_vehicles=1`, `max_garages=1`, `subscription_plan` a raw slug-ot mutatta.
### Root Cause
**Backend** ([`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py), `read_users_me()`, sorok 207-312):
A subscription limit feloldás két párhuzamos, egymástól független kódrészben történt (`max_vehicles`/`max_garages` külön, `subscription_valid_until`/`tier_id` külön). Amikor a user-nek volt `active_org_id`-je (scope_id=67) de NEM volt org subscription rekordja, a kód a fallback `Organization.base_asset_limit`-et használta (=1), és SOHA nem esett át a user subscription ágra.
**Adatbázis** ([`system.subscription_tiers`](system.subscription_tiers), id=14): A `private_pro_v1` rules JSONB-ben a `display_name` mező NULL volt.
### Javítás
1. **Backend**: Unifikált subscription resolution — egyesített subscription_record lookup, ami először org subscription-t keres, ha nincs, user subscription-t, majd mindkét értéket (limits + metadata) ugyanabból a rekordból nyeri ki.
2. **Adatbázis**: `UPDATE system.subscription_tiers SET rules = jsonb_set(rules, '{display_name}', '"Privát Pro"') WHERE id=14`
### Verifikáció
-`GET /api/v1/users/me` admin user-re: `max_vehicles=10`, `max_garages=2`, `subscription_display_name="Privát VIP"`, `subscription_tier_id=15`, `subscription_expires_at=2026-08-27`
- ✅ Frontend i18n kulcsok (`renewNow`, `upgradePlan`, `clickToFlipBack`) megtalálhatóak mind a `hu.ts`-ben, mind az `en.ts`-ben
- ✅ Sync Engine: 1323 elem, tökéletes szinkron
- ✅ private_pro_v1 display_name javítva "Privát Pro"-ra
---
## Fix: Context-Switching Data Leak — Dashboard Not Refreshing on Garage Switch
**Dátum:** 2026-07-28
### Probléma
When the user switched between private garages (e.g., "Profibot Tester" ↔ "User Admin") using the `HeaderCompanySwitcher` dropdown, the dashboard continued to display stale data:
1. **Vehicle list** (MyVehiclesCard) showed the same vehicles regardless of which private garage was active
2. **Wallet balance** (ProfileTrustCard) showed the same 705 000 balance
3. **Statistics** didn't update
### Root Cause Analysis
**Frontend — 3 layers of broken communication:**
1. **Cache staleness:** `switchOrganization()` in `auth.ts` correctly called the backend `PATCH /users/me/active-organization`, received a new JWT token, and updated `user.value`. However, neither `vehicleStore` nor `financeStore` were notified to refresh their data. Both stores had cached data from the initial `onMounted()` call.
2. **Mount-once issue:** `DashboardView.onMounted()` only runs once because the Vue Router reuses the same component instance when the route path doesn't change (`/dashboard``/dashboard`). The `watch` on `authStore.contextVersion` didn't exist — there was no reactive trigger to re-fetch dashboard data after context switch.
3. **Deduplication guard blocking re-fetch:** Even if `fetchVehicles()` was called manually after context switch, the `_vehicleFetchInProgress` dedup guard would return the stale cached data. The guard was designed to prevent duplicate calls during initial mount, but it also blocked legitimate re-fetches after context change.
**Backend — partially correct:**
- The vehicles endpoint (`GET /assets/vehicles`) already uses `current_user.scope_id` to filter vehicles by organization — this was correct.
- The wallet balance endpoint (`GET /billing/wallet/balance`) uses `current_user.id` only, meaning it returns the user's global wallet regardless of context — this is by-design since wallet is user-scoped, not org-scoped. However, the frontend wasn't even calling it again after context switch.
**Data Ownership — why two private garages appear:**
The user sees two private garages because they are a member of two individual organizations. This is expected behavior for users who have created multiple private garages or were added to multiple. The issue is not why they appear, but that switching between them doesn't refresh dashboard data.
### Javítás (3 files changed)
**1. `frontend_app/src/stores/auth.ts`:**
- Added `contextVersion` ref — a reactive version counter that increments on every successful `switchOrganization()` call
- Exposed `contextVersion` in the return block so DashboardView can watch it
**2. `frontend_app/src/stores/vehicle.ts`:**
- Added `clearCache()` method that resets `_vehicleFetchInProgress`, clears `vehicles.value`, and resets `error` — this breaks the dedup guard so a subsequent `fetchVehicles()` actually executes a new API call
**3. `frontend_app/src/views/DashboardView.vue`:**
- Added a `watch(() => authStore.contextVersion, ...)` that triggers when `contextVersion` increments
- On context change: calls `vehicleStore.clearCache()`, `vehicleStore.fetchVehicles()`, `financeStore.fetchWalletBalance()`, and `authStore.fetchMyOrganizations()`
- Skips the first trigger (`contextVersion === 0`) because `onMounted()` already fetches initial data
### Verifikáció
-`authStore.contextVersion` incremented on every `switchOrganization()` call
-`vehicleStore.clearCache()` resets dedup guard before re-fetch
- ✅ DashboardView watches `contextVersion` and re-fetches all context-scoped data on change
- ✅ Wallet balance re-fetched after context switch
- ✅ Organizations list refreshed for header switcher sync
---
## P0: Avatar Menu Subscription Modal - planDisplayName bugfix & DB audit (Gitea #433)
**Date:** 2026-07-28
### DB Investigation: admin@profibot.hu
- Found user id=2 with 3 org memberships: 2 individual garages (org 1: 19 vehicles, org 67: 3 archived) + 1 service_provider (org 57: 0 vehicles)
- **No data duplication** — each org has distinct vehicles via `vehicle.assets.current_organization_id`
- The "azonos adat" perception was caused by TEST-XXX/DRAFT-XXX pattern vehicles in org 1
### Fix: SubscriptionInfoModal.vue planDisplayName
- **Root cause:** `planDisplayName` returned raw `subscription_plan` slug (e.g. `private_vip_v1`) instead of the backend-supplied `subscription_display_name`
- **Fix:** Replaced simple `return plan` with a 3-level fallback matching `SubscriptionStatusWidget.vue:124-150`:
1. `authStore.user?.subscription_display_name` (from `_resolve_subscription_data()`)
2. Static slug→human mapping (`private_vip_v1``Privát VIP`)
3. Raw plan name as ultimate fallback
- `max_vehicles`/`max_garages`/progress bars were already correctly bound to backend data — no changes needed
### Files changed:
- `frontend_app/src/components/subscription/SubscriptionInfoModal.vue` (line 174-201)
- `docs/subscription_info_modal_planDisplayName_bug_analysis.md` (new)

View File

@@ -256,8 +256,15 @@ async def get_user_vehicles(
# Use a raw SQL expression for JSONB boolean ordering # Use a raw SQL expression for JSONB boolean ordering
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST") order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None: # P0 BUGFIX (2026-07-28): Staff users (ADMIN, SUPERADMIN, MODERATOR, etc.)
# Personal mode: show vehicles in organizations where user is a member # should NOT enter corporate mode even if scope_id is set. They need to
# see vehicles through the personal/org membership path.
staff_roles = {'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR'}
current_role = current_user.role.upper() if current_user.role else ''
is_staff_user = current_role in staff_roles
if current_user.scope_id is None or is_staff_user:
# Personal mode OR staff user: show vehicles in organizations where user is a member
org_stmt = select(OrganizationMember.organization_id).where( org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id OrganizationMember.user_id == current_user.id
) )

View File

@@ -1,9 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_ from sqlalchemy import select, func, and_
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
import logging import logging
import uuid import uuid
from decimal import Decimal
from app.api.deps import get_db, get_current_user from app.api.deps import get_db, get_current_user
from app.models.identity import User, Wallet, UserRole from app.models.identity import User, Wallet, UserRole
@@ -13,6 +15,8 @@ from app.services.config_service import config
from app.services.payment_router import PaymentRouter from app.services.payment_router import PaymentRouter
from app.services.stripe_adapter import stripe_adapter from app.services.stripe_adapter import stripe_adapter
from app.services.billing_engine import upgrade_subscription, get_user_balance from app.services.billing_engine import upgrade_subscription, get_user_balance
from app.services.subscription_activator import SubscriptionActivator
from app.core.config import settings
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -407,3 +411,302 @@ async def get_wallet_transactions(
except Exception as e: except Exception as e:
logger.error(f"Tranzakció történet lekérdezési hiba: {e}") logger.error(f"Tranzakció történet lekérdezési hiba: {e}")
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}") raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
# ──────────────────────────────────────────────────────────────────────────────
# Mock Payment Gateway — Checkout Page & Webhook Callback (Phase 2)
# ──────────────────────────────────────────────────────────────────────────────
#
# THOUGHT PROCESS:
# These two endpoints simulate a real payment gateway lifecycle:
#
# 1. GET /mock-payment/checkout/{intent_id}
# Displays a simple HTML page with "Pay Now" button.
# The user sees an amount and clicks to confirm payment.
# This is the URL returned as `checkout_url` by MockPaymentGateway
# in `simulate_redirect` mode.
#
# 2. POST /mock-payment/callback
# Simulates the webhook callback that a real payment gateway
# (like Stripe) would send after successful payment.
# Updates PaymentIntent PENDING → COMPLETED and activates
# the subscription using the metadata stored by FinancialManager.
#
# Together, these allow frontend testing of the full payment flow:
# purchase → redirect → pay → callback → activate subscription
# without requiring real Stripe integration.
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/mock-payment/checkout/{intent_id}")
async def mock_checkout_page(
intent_id: str,
db: AsyncSession = Depends(get_db),
):
"""
Mock payment checkout page.
Displays a simple HTML page showing the payment amount and a "Pay Now"
button. When the user clicks the button, it POSTs to the mock callback
endpoint which finalizes the PaymentIntent and activates the subscription.
This simulates redirecting the user to a banking/payment portal.
Args:
intent_id: The mock gateway intent ID (stored as stripe_session_id).
db: Database session.
Returns:
HTMLResponse with a styled mock checkout page, or 404 if not found.
"""
logger.info(
"[MOCK_PAYMENT_CHECKOUT] Page requested for intent: %s",
intent_id,
)
# Look up the PaymentIntent by stripe_session_id (= gateway intent_id)
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
logger.warning(
"[MOCK_PAYMENT_CHECKOUT] PaymentIntent not found or not PENDING: "
"intent_id=%s", intent_id,
)
return HTMLResponse(
content="<h1>Payment not found or already processed</h1>",
status_code=404,
)
amount = float(payment_intent.gross_amount)
currency = payment_intent.currency
# Determine the callback URL: POST back to the same origin
callback_url = f"/api/v1/billing/mock-payment/callback"
# Simple but styled HTML checkout page
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mock Payment Gateway</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; justify-content: center; align-items: center;
min-height: 100vh; padding: 20px;
}}
.card {{
background: white; border-radius: 16px; padding: 40px;
max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}}
.card h1 {{ font-size: 24px; color: #1a1a2e; margin-bottom: 8px; }}
.card .subtitle {{ color: #666; font-size: 14px; margin-bottom: 24px; }}
.amount {{
font-size: 48px; font-weight: 700; color: #1a1a2e;
margin: 20px 0; padding: 20px 0;
border-top: 2px solid #f0f0f0; border-bottom: 2px solid #f0f0f0;
}}
.amount .currency {{ font-size: 24px; color: #666; }}
.details {{ text-align: left; margin: 20px 0; padding: 16px; background: #f8f9fa; border-radius: 8px; }}
.details dt {{ font-size: 12px; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }}
.details dd {{ font-size: 14px; color: #333; margin-bottom: 8px; }}
.btn-pay {{
display: inline-block; padding: 14px 48px; margin-top: 16px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white; border: none; border-radius: 8px;
font-size: 18px; font-weight: 600; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
}}
.btn-pay:hover {{ transform: translateY(-2px); box-shadow: 0 8px 24px rgba(34,197,94,0.4); }}
.btn-pay:active {{ transform: translateY(0); }}
.footer {{ margin-top: 20px; font-size: 12px; color: #999; }}
.badge {{
display: inline-block; padding: 4px 12px; border-radius: 20px;
background: #fef3c7; color: #92400e; font-size: 12px; font-weight: 500;
margin-bottom: 16px;
}}
</style>
</head>
<body>
<div class="card">
<div class="badge">🧪 Mock Gateway — Development Mode</div>
<h1>Complete Your Payment</h1>
<p class="subtitle">This is a simulated payment page for testing</p>
<div class="amount">
{amount:.2f} <span class="currency">{currency}</span>
</div>
<dl class="details">
<dt>Intent ID</dt>
<dd>{intent_id[:20]}...</dd>
<dt>Description</dt>
<dd>Subscription Package Purchase</dd>
</dl>
<form action="{callback_url}" method="POST">
<input type="hidden" name="intent_id" value="{intent_id}">
<button type="submit" class="btn-pay">
✅ Pay {amount:.2f} {currency}
</button>
</form>
<p class="footer">
🔒 Connection simulated &bull; No real payment will be charged
</p>
</div>
</body>
</html>"""
logger.info(
"[MOCK_PAYMENT_CHECKOUT] Serving checkout page: intent=%s amount=%.2f %s",
intent_id, amount, currency,
)
return HTMLResponse(content=html_content)
@router.post("/mock-payment/callback")
async def mock_payment_callback(
intent_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""
Mock payment gateway callback (webhook simulation).
Simulates the webhook that a real payment gateway would send after
successful payment. This endpoint:
1. Looks up the PENDING PaymentIntent by gateway intent_id
2. Updates it to COMPLETED
3. Activates the subscription using stored metadata (tier_id, user_id, org_id)
4. Returns a redirect to the frontend success page (or JSON response)
Args:
intent_id: The mock gateway intent ID (Form field).
db: Database session.
Returns:
RedirectResponse to the frontend success URL, or JSONResponse if
the frontend URL is not configured.
"""
logger.info(
"[MOCK_PAYMENT_CALLBACK] Received callback for intent: %s",
intent_id,
)
# ── Step 1: Look up the PaymentIntent ─────────────────────────────────
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
logger.warning(
"[MOCK_PAYMENT_CALLBACK] PaymentIntent not found or not PENDING: "
"intent_id=%s", intent_id,
)
return JSONResponse(
{"error": "PaymentIntent not found or not PENDING"},
status_code=404,
)
logger.info(
"[MOCK_PAYMENT_CALLBACK] PaymentIntent found: id=%d amount=%.2f %s",
payment_intent.id,
float(payment_intent.gross_amount),
payment_intent.currency,
)
# ── Step 2: Update PaymentIntent to COMPLETED ─────────────────────────
payment_intent.status = PaymentIntentStatus.COMPLETED
payment_intent.completed_at = datetime.utcnow()
# ── Step 3: Activate the subscription ─────────────────────────────────
# Read the purchase context from metadata (stored by FinancialManager)
meta = payment_intent.meta_data or {}
tier_id = meta.get("tier_id")
user_id = meta.get("user_id")
org_id = meta.get("org_id")
duration_days = meta.get("duration_days")
subscription_id = None
if tier_id and user_id:
activator = SubscriptionActivator()
try:
if org_id:
org_sub = await activator.activate_org_subscription(
db=db, org_id=int(org_id), tier_id=int(tier_id),
duration_days=int(duration_days) if duration_days else None,
)
subscription_id = org_sub.id
logger.info(
"[MOCK_PAYMENT_CALLBACK] Org subscription activated: "
"org_id=%s sub_id=%d", org_id, org_sub.id,
)
else:
user_sub = await activator.activate_user_subscription(
db=db, user_id=int(user_id), tier_id=int(tier_id),
duration_days=int(duration_days) if duration_days else None,
)
subscription_id = user_sub.id
logger.info(
"[MOCK_PAYMENT_CALLBACK] User subscription activated: "
"user_id=%s sub_id=%d", user_id, user_sub.id,
)
except Exception as e:
logger.error(
"[MOCK_PAYMENT_CALLBACK] Subscription activation failed: %s",
str(e),
)
# Don't fail the callback — the PaymentIntent is already COMPLETED
# The subscription can be activated manually or via retry logic
else:
logger.warning(
"[MOCK_PAYMENT_CALLBACK] Missing tier_id or user_id in metadata. "
"Subscription was NOT activated. metadata=%s", meta,
)
await db.commit()
logger.info(
"[MOCK_PAYMENT_CALLBACK] Payment completed successfully: "
"intent=%s, payment_intent_id=%d, amount=%.2f %s, "
"subscription_id=%s",
intent_id, payment_intent.id,
float(payment_intent.gross_amount), payment_intent.currency,
subscription_id,
)
# ── Step 4: Redirect to frontend ──────────────────────────────────────
# In production, redirect to the frontend success page.
# If FRONTEND_URL is not configured, return JSON.
frontend_url = getattr(settings, "FRONTEND_URL", None) or getattr(config, "FRONTEND_URL", None)
if frontend_url:
redirect_url = f"{frontend_url}/dashboard/subscription?payment=success"
logger.info(
"[MOCK_PAYMENT_CALLBACK] Redirecting to frontend: %s",
redirect_url,
)
return RedirectResponse(url=redirect_url, status_code=302)
# Fallback: return JSON response
return JSONResponse({
"success": True,
"payment_intent_id": payment_intent.id,
"transaction_id": str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
"subscription_id": subscription_id,
"message": "Payment completed successfully",
})

View File

@@ -42,7 +42,15 @@ def get_financial_manager() -> FinancialManager:
""" """
Dependency that provides a configured FinancialManager instance. Dependency that provides a configured FinancialManager instance.
Currently uses MockPaymentGateway as default. To switch to Stripe: Uses MockPaymentGateway in simulate_redirect mode as default.
In this mode, paid tiers (> 0 EUR) generate a checkout URL;
the frontend redirects the user to a mock payment page, and a
webhook callback (POST /billing/mock-payment/callback) finalizes
the payment and activates the subscription.
Free tiers ($0) bypass payment entirely and are activated immediately.
To switch to Stripe:
from app.services.stripe_adapter import StripeAdapter from app.services.stripe_adapter import StripeAdapter
return FinancialManager(payment_gateway=StripeAdapter()) return FinancialManager(payment_gateway=StripeAdapter())
@@ -50,7 +58,7 @@ def get_financial_manager() -> FinancialManager:
A FinancialManager instance with the configured payment gateway. A FinancialManager instance with the configured payment gateway.
""" """
return FinancialManager( return FinancialManager(
payment_gateway=MockPaymentGateway(mode="auto_approve"), payment_gateway=MockPaymentGateway(mode="simulate_redirect"),
) )

View File

@@ -139,28 +139,61 @@ async def get_public_subscriptions(
**Szabály:** **Szabály:**
- Csak azokat a csomagokat adja vissza, ahol a `rules.lifecycle.is_public` = true - Csak azokat a csomagokat adja vissza, ahol a `rules.lifecycle.is_public` = true
(JSONB mező alapján szűrve). Ha a mező hiányzik, alapértelmezés szerint publikus. (JSONB mező alapján szűrve). Ha a mező hiányzik, alapértelmezés szerint publikus.
- Defense-in-Depth: A `rules.type` mező alapján szűrjük a csomagokat a felhasználó
aktuális kontextusa szerint. Ha a user aktív szervezete `individual` típusú (vagy
nincs aktív szervezet), csak `private`/`consumer` típusú csomagokat adunk vissza.
Ha a szervezet business típusú, csak `corporate`/`business` típusú csomagokat.
- Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó - Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó
országkódja alapján feloldott árazást tartalmazza. országkódja alapján feloldott árazást tartalmazza.
- A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag).
""" """
# 1. Determine user's country code # 1. Determine user's country code
country_code = await get_user_country_code(current_user, db) country_code = await get_user_country_code(current_user, db)
# 2. Fetch all public tiers # 2. Determine if user is in corporate context (defense-in-depth)
active_org_id = getattr(current_user, "active_organization_id", None)
is_corporate = False
if active_org_id:
org_type_stmt = select(Organization.org_type).where(
Organization.id == active_org_id,
Organization.is_deleted == False,
)
org_type_result = await db.execute(org_type_stmt)
org_type = org_type_result.scalar_one_or_none()
if org_type and org_type != "individual":
is_corporate = True
# 3. Fetch all public tiers (filter out non-public at SQL level)
stmt = ( stmt = (
select(SubscriptionTier) select(SubscriptionTier)
.where(
# Only show tiers where lifecycle.is_public is NOT explicitly 'false'
# Using SQLAlchemy JSONB path query (same approach as admin_packages.py line 134)
~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
)
.order_by(SubscriptionTier.id) .order_by(SubscriptionTier.id)
) )
result = await db.execute(stmt) result = await db.execute(stmt)
tiers = result.scalars().all() tiers = result.scalars().all()
# 3. Build response with resolved pricing # 4. Build response with resolved pricing and type filtering
response: List[PublicSubscriptionTierResponse] = [] response: List[PublicSubscriptionTierResponse] = []
for t in tiers: for t in tiers:
rules = t.rules or {} rules = t.rules or {}
if not rules.get("lifecycle", {}).get("is_public", True): if not rules.get("lifecycle", {}).get("is_public", True):
continue continue
# Defense-in-Depth: Filter by target audience type
rules_type = rules.get("type", None)
if rules_type:
if is_corporate:
# In corporate context: only show corporate/business packages
if rules_type not in ("corporate", "business"):
continue
else:
# In private context: only show private/consumer packages
if rules_type in ("corporate", "business"):
continue
# Resolve pricing for this user's country # Resolve pricing for this user's country
resolved = resolve_pricing(rules, country_code) resolved = resolve_pricing(rules, country_code)
resolved_pricing = None resolved_pricing = None
@@ -253,12 +286,57 @@ async def get_my_subscription(
user_tier = await SubscriptionService.get_user_tier(db, user_id) user_tier = await SubscriptionService.get_user_tier(db, user_id)
feature_flags = await SubscriptionService.get_user_feature_flags(db, user_id) feature_flags = await SubscriptionService.get_user_feature_flags(db, user_id)
# ── P0 PENDING DOWNGRADE FIELDS (Issue #429) ──
# Check if there's a pending downgrade on the current subscription
has_pending_downgrade = False
pending_tier_name = None
pending_effective_date = None
if subscription_data:
pending_sub = None
if source == "organization" and active_org_id:
pending_stmt = select(OrganizationSubscription).options(
selectinload(OrganizationSubscription.pending_tier)
).where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.pending_tier_id.is_not(None),
).order_by(OrganizationSubscription.id.desc()).limit(1)
pending_result = await db.execute(pending_stmt)
pending_sub = pending_result.scalar_one_or_none()
elif source == "user":
pending_stmt = select(UserSubscription).options(
selectinload(UserSubscription.pending_tier)
).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
UserSubscription.pending_tier_id.is_not(None),
).order_by(UserSubscription.id.desc()).limit(1)
pending_result = await db.execute(pending_stmt)
pending_sub = pending_result.scalar_one_or_none()
if pending_sub and pending_sub.pending_tier:
has_pending_downgrade = True
pt = pending_sub.pending_tier
pending_tier_name = (
pt.rules.get("display_name", pt.name)
if pt.rules else pt.name
)
pending_effective_date = (
pending_sub.valid_until.isoformat()
if pending_sub.valid_until else None
)
return { return {
"subscription": subscription_data, "subscription": subscription_data,
"tier": user_tier, "tier": user_tier,
"features": feature_flags.get("features", {}), "features": feature_flags.get("features", {}),
"expires_at": feature_flags.get("expires_at"), "expires_at": feature_flags.get("expires_at"),
"source": source, "source": source,
# ── P0 Pending downgrade fields ──
"has_pending_downgrade": has_pending_downgrade,
"pending_tier_name": pending_tier_name,
"pending_effective_date": pending_effective_date,
} }

View File

@@ -42,6 +42,155 @@ class NetworkResponse(BaseModel):
level3: List[NetworkMemberL2L3] level3: List[NetworkMemberL2L3]
async def _resolve_subscription_data(
db: AsyncSession,
user: User,
active_org_id: Optional[int] = None,
) -> dict:
"""
P0 BUGFIX (2026-07-28): Unified subscription resolution shared by /auth/me and /users/me.
Resolves the user's real subscription limits (max_vehicles, max_garages),
display_name, expiry, auto-renewal fields, and active add-ons from the
assigned subscription_tier JSONB rules.
Resolution order:
1. Org-level subscription (OrganizationSubscription) if active_org_id is set
2. User-level subscription (UserSubscription) fallback
3. Organization.base_asset_limit as ultimate fallback
Returns a dict with all subscription-related fields ready to merge into
the user response.
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription
from app.models.marketplace.organization import Organization
tier: Optional[SubscriptionTier] = None
subscription_record = None
# Step 1: Try org-level subscription first
if active_org_id is not None:
org_sub_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
subscription_record = (await db.execute(org_sub_stmt)).scalar_one_or_none()
if subscription_record and subscription_record.tier_id:
tier = await db.get(SubscriptionTier, subscription_record.tier_id)
# Step 2: Fall back to user-level subscription (runs even when active_org_id is set)
if subscription_record is None:
user_sub_stmt = (
select(UserSubscription)
.where(
UserSubscription.user_id == user.id,
UserSubscription.is_active == True,
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
subscription_record = (await db.execute(user_sub_stmt)).scalar_one_or_none()
if subscription_record and subscription_record.tier_id:
tier = await db.get(SubscriptionTier, subscription_record.tier_id)
# Step 3: Extract limits from the resolved tier or fall back
max_vehicles = 1
max_garages = 1
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
elif active_org_id is not None:
org_stmt = select(Organization).where(Organization.id == active_org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org:
max_vehicles = org.base_asset_limit or 1
max_garages = 1
# Step 4: Extract metadata from the resolved subscription record
subscription_display_name: Optional[str] = None
subscription_valid_until: Optional[datetime] = None
subscription_tier_id: Optional[int] = None
subscription_valid_from: Optional[datetime] = None
subscription_auto_renew: bool = False
subscription_next_renewal_date: Optional[datetime] = None
subscription_wallet_auto_deduct: bool = False
if subscription_record is not None:
subscription_valid_until = subscription_record.valid_until
subscription_tier_id = subscription_record.tier_id
subscription_valid_from = subscription_record.valid_from
subscription_auto_renew = getattr(subscription_record, 'auto_renew', False)
subscription_next_renewal_date = getattr(subscription_record, 'next_renewal_date', None)
subscription_wallet_auto_deduct = getattr(subscription_record, 'wallet_auto_deduct', False)
# Resolve display_name from the resolved tier
if tier and tier.rules:
subscription_display_name = tier.rules.get("display_name", None)
# Step 5: Resolve active add-on subscriptions
active_addons: List[Dict[str, Any]] = []
if active_org_id is not None:
addon_stmt = (
select(OrganizationSubscription, SubscriptionTier)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
SubscriptionTier.type == 'addon',
)
.order_by(OrganizationSubscription.valid_from.desc())
)
addon_result = await db.execute(addon_stmt)
for sub, tier_row in addon_result:
active_addons.append({
"tier_id": tier_row.id,
"display_name": tier_row.rules.get("display_name", tier_row.name) if tier_row.rules else tier_row.name,
"valid_from": sub.valid_from.isoformat() if sub.valid_from else None,
"valid_until": sub.valid_until.isoformat() if sub.valid_until else None,
"is_active": sub.is_active,
})
else:
addon_stmt = (
select(UserSubscription, SubscriptionTier)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == user.id,
UserSubscription.is_active == True,
SubscriptionTier.type == 'addon',
)
.order_by(UserSubscription.valid_from.desc())
)
addon_result = await db.execute(addon_stmt)
for sub, tier_row in addon_result:
active_addons.append({
"tier_id": tier_row.id,
"display_name": tier_row.rules.get("display_name", tier_row.name) if tier_row.rules else tier_row.name,
"valid_from": sub.valid_from.isoformat() if sub.valid_from else None,
"valid_until": sub.valid_until.isoformat() if sub.valid_until else None,
"is_active": sub.is_active,
})
return {
"max_vehicles": max_vehicles,
"max_garages": max_garages,
"subscription_display_name": subscription_display_name,
"subscription_expires_at": subscription_valid_until.isoformat() if subscription_valid_until else None,
"subscription_tier_id": subscription_tier_id,
"subscription_valid_from": subscription_valid_from.isoformat() if subscription_valid_from else None,
"subscription_auto_renew": subscription_auto_renew,
"subscription_next_renewal_date": subscription_next_renewal_date.isoformat() if subscription_next_renewal_date else None,
"subscription_wallet_auto_deduct": subscription_wallet_auto_deduct,
"active_addons": active_addons,
"subscription_plan": user.subscription_plan,
}
async def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict: async def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
""" """
Segédfüggvény a UserResponse dict előállításához. Segédfüggvény a UserResponse dict előállításához.
@@ -113,10 +262,26 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
if db is not None: if db is not None:
pass pass
# ── P0: Resolve subscription limits ── # ── P0: Resolve subscription limits via shared function ──
# Default fallback values # P0 BUGFIX (2026-07-28): Previously hardcoded max_vehicles=1 / max_garages=1.
max_vehicles = 1 # Now uses _resolve_subscription_data() which queries the actual subscription_tier
max_garages = 1 # JSONB rules. This fixes both /auth/me and /users/me endpoints in one place.
if db is not None:
sub_data = await _resolve_subscription_data(db, user, active_org_id)
else:
sub_data = {
"max_vehicles": 1,
"max_garages": 1,
"subscription_display_name": None,
"subscription_expires_at": None,
"subscription_tier_id": None,
"subscription_valid_from": None,
"subscription_auto_renew": False,
"subscription_next_renewal_date": None,
"subscription_wallet_auto_deduct": False,
"active_addons": [],
"subscription_plan": user.subscription_plan,
}
return { return {
"id": user.id, "id": user.id,
@@ -127,9 +292,19 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
"region_code": user.region_code, "region_code": user.region_code,
"person_id": user.person_id, "person_id": user.person_id,
"role": role_key, "role": role_key,
"subscription_plan": user.subscription_plan, "subscription_plan": sub_data["subscription_plan"],
"max_vehicles": max_vehicles, "max_vehicles": sub_data["max_vehicles"],
"max_garages": max_garages, "max_garages": sub_data["max_garages"],
"subscription_display_name": sub_data["subscription_display_name"],
"subscription_expires_at": sub_data["subscription_expires_at"],
"subscription_tier_id": sub_data["subscription_tier_id"],
"subscription_valid_from": sub_data["subscription_valid_from"],
"subscription_auto_renew": sub_data["subscription_auto_renew"],
"subscription_next_renewal_date": sub_data["subscription_next_renewal_date"],
"subscription_wallet_auto_deduct": sub_data["subscription_wallet_auto_deduct"],
"active_addons": sub_data["active_addons"],
"user_registration_date": user.created_at.isoformat() if user.created_at else None,
"created_at": user.created_at.isoformat() if user.created_at else None,
"scope_level": user.scope_level or "individual", "scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None, "scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": user.ui_mode or "personal", "ui_mode": user.ui_mode or "personal",
@@ -204,60 +379,21 @@ async def read_users_me(
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id) is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
# ── P0: Resolve real subscription limits from the assigned tier ── # ── P0: Resolve real subscription limits from the assigned tier ──
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription # P0 BUGFIX (2026-07-28): Uses the shared _resolve_subscription_data() function
max_vehicles = 1 # instead of duplicating the subscription resolution logic inline.
max_garages = 1 # This fixes both /auth/me and /users/me in one place.
#
if active_org_id is not None: # _build_user_response() already calls _resolve_subscription_data() internally,
# Try org-level subscription first # so _build_user_response now returns all subscription fields (max_vehicles,
org_sub_stmt = ( # subscription_display_name, etc.) in the base response. We just need to
select(SubscriptionTier) # override is_last_admin and org_capabilities post-build.
.select_from(OrganizationSubscription) sub_data = await _resolve_subscription_data(db, current_user, active_org_id)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(org_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
else:
# Fallback: read base_asset_limit from Organization record
org_stmt = select(Organization).where(Organization.id == active_org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org:
max_vehicles = org.base_asset_limit or 1
max_garages = 1 # No base_garage_limit column, default to 1
else:
# Personal mode: try user-level subscription
user_sub_stmt = (
select(SubscriptionTier)
.select_from(UserSubscription)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == current_user.id,
UserSubscription.is_active == True
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(user_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
# ── RBAC Phase 3: Build base response ── # ── RBAC Phase 3: Build base response ──
# P0 Phase 6: Pass db so system_capabilities gets populated from DB # P0 Phase 6: Pass db so system_capabilities gets populated from DB
response_data = await _build_user_response(current_user, active_org_id, db) response_data = await _build_user_response(current_user, active_org_id, db)
response_data["is_last_admin"] = is_last_admin response_data["is_last_admin"] = is_last_admin
response_data["max_vehicles"] = max_vehicles # All subscription fields are already in response_data from _build_user_response
response_data["max_garages"] = max_garages
# ── RBAC Phase 3: Resolve org_capabilities ── # ── RBAC Phase 3: Resolve org_capabilities ──
# Get all organizations the user is a member of # Get all organizations the user is a member of

View File

@@ -164,12 +164,60 @@ class OrganizationSubscription(Base):
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100} # Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict) extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
# ── P0 AUTO-RENEWAL FIELDS ──
auto_renew: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Whether this subscription auto-renews at expiry"
)
provider_subscription_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
comment="External payment gateway subscription ID (e.g., Stripe sub_xxx)"
)
wallet_auto_deduct: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Auto-deduct from wallet at renewal time"
)
renewal_failure_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0"),
comment="Consecutive failed renewal attempts"
)
last_renewal_attempt: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Timestamp of the most recent renewal attempt"
)
next_renewal_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Pre-calculated next auto-renewal date (denormalized from valid_until for fast cron queries)"
)
# ── P0 PENDING DOWNGRADE FIELDS (Issue #429) ──
# When a user switches to a cheaper/free tier, the change takes effect
# only after the current, already-paid subscription period expires.
# pending_tier_id stores the target tier for the delayed activation.
pending_tier_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("system.subscription_tiers.id"), nullable=True,
default=None, server_default=text("NULL"),
comment="If set, this is a pending downgrade that activates after current period expires (FK → system.subscription_tiers.id)"
)
pending_activated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
default=None, server_default=text("NULL"),
comment="When the pending downgrade was requested (audit trail)"
)
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ── # ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
tier: Mapped[Optional["SubscriptionTier"]] = relationship( tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier", "SubscriptionTier",
foreign_keys=[tier_id], foreign_keys=[tier_id],
lazy="selectin", lazy="selectin",
) )
# ── P0 PENDING DOWNGRADE: kapcsolat a pending tier-hez ──
pending_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[pending_tier_id],
lazy="selectin",
)
class UserSubscription(Base): class UserSubscription(Base):
""" """
@@ -193,12 +241,60 @@ class UserSubscription(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# ── P0 AUTO-RENEWAL FIELDS ──
auto_renew: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Whether this subscription auto-renews at expiry"
)
provider_subscription_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
comment="External payment gateway subscription ID (e.g., Stripe sub_xxx)"
)
wallet_auto_deduct: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Auto-deduct from wallet at renewal time"
)
renewal_failure_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0"),
comment="Consecutive failed renewal attempts"
)
last_renewal_attempt: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Timestamp of the most recent renewal attempt"
)
next_renewal_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Pre-calculated next auto-renewal date (denormalized from valid_until for fast cron queries)"
)
# ── P0 PENDING DOWNGRADE FIELDS (Issue #429) ──
# When a user switches to a cheaper/free tier, the change takes effect
# only after the current, already-paid subscription period expires.
# pending_tier_id stores the target tier for the delayed activation.
pending_tier_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("system.subscription_tiers.id"), nullable=True,
default=None, server_default=text("NULL"),
comment="If set, this is a pending downgrade that activates after current period expires (FK → system.subscription_tiers.id)"
)
pending_activated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
default=None, server_default=text("NULL"),
comment="When the pending downgrade was requested (audit trail)"
)
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ── # ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
tier: Mapped[Optional["SubscriptionTier"]] = relationship( tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier", "SubscriptionTier",
foreign_keys=[tier_id], foreign_keys=[tier_id],
lazy="selectin", lazy="selectin",
) )
# ── P0 PENDING DOWNGRADE: kapcsolat a pending tier-hez ──
pending_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[pending_tier_id],
lazy="selectin",
)
class CreditTransaction(Base): class CreditTransaction(Base):

View File

@@ -50,7 +50,8 @@ class PurchaseResponse(BaseModel):
Response schema for a completed package purchase. Response schema for a completed package purchase.
Contains the full receipt: payment details, subscription info, Contains the full receipt: payment details, subscription info,
and commission distribution results. commission distribution results, and optionally a checkout_url
for paid tiers in simulate_redirect mode.
""" """
success: bool = Field(..., description="Whether the purchase was successful") success: bool = Field(..., description="Whether the purchase was successful")
payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID") payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID")
@@ -63,6 +64,8 @@ class PurchaseResponse(BaseModel):
currency: str = Field("EUR", description="The payment currency") currency: str = Field("EUR", description="The payment currency")
gateway: str = Field("mock", description="The payment gateway used") gateway: str = Field("mock", description="The payment gateway used")
gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID") gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID")
checkout_url: Optional[str] = Field(None, description="Checkout URL for payment redirect (simulate_redirect mode)")
payment_status: Optional[str] = Field(None, description="Payment status: PENDING, COMPLETED, etc.")
is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription") is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription")
commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results") commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results")
error: Optional[str] = Field(None, description="Error message if success=False") error: Optional[str] = Field(None, description="Error message if success=False")

View File

@@ -22,7 +22,7 @@ from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# ── Belső (embedded) modellek a rules JSONB struktúrához ────────────────────── # ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
@@ -58,6 +58,16 @@ class DurationModel(BaseModel):
allow_stacking: bool = Field(default=True, description="Időhalmozás engedélyezése (stacking): ha True, a meglévő valid_until-hoz hozzáadódik") allow_stacking: bool = Field(default=True, description="Időhalmozás engedélyezése (stacking): ha True, a meglévő valid_until-hoz hozzáadódik")
class RenewalConfigModel(BaseModel):
"""Auto-renewal beállítások egy előfizetési csomaghoz (tier.rules.renewal JSONB)."""
enabled: bool = Field(default=False, description="Can this tier be auto-renewed?")
auto_renew_default: bool = Field(default=False, description="Default value for auto_renew when subscribing")
renewal_period_days: Optional[int] = Field(default=None, ge=1, description="Renewal period in days (None = use duration.days)")
retry_on_failure: bool = Field(default=True, description="Retry failed renewal attempts?")
max_retry_count: int = Field(default=3, ge=0, description="Max consecutive failed retries before disabling auto-renew")
grace_period_days: int = Field(default=7, ge=0, description="Grace period in days after expiry before suspension")
class AdPolicyModel(BaseModel): class AdPolicyModel(BaseModel):
"""Hirdetési szabályok a csomaghoz.""" """Hirdetési szabályok a csomaghoz."""
show_ads: bool = Field(default=True, description="Megjelenjenek-e hirdetések a felhasználónak") show_ads: bool = Field(default=True, description="Megjelenjenek-e hirdetések a felhasználónak")
@@ -98,6 +108,7 @@ class SubscriptionRulesModel(BaseModel):
affiliate: Partnerprogram beállítások affiliate: Partnerprogram beállítások
lifecycle: Életciklus állapotok (opcionális, pl. is_public) lifecycle: Életciklus állapotok (opcionális, pl. is_public)
duration: Előfizetés időtartamának beállításai (duration_days, stacking) duration: Előfizetés időtartamának beállításai (duration_days, stacking)
renewal: Auto-renewal beállítások (enabled, auto_renew_default, retry_on_failure)
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days) ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color) marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
""" """
@@ -131,6 +142,10 @@ class SubscriptionRulesModel(BaseModel):
default=None, default=None,
description="Előfizetés időtartamának beállításai (duration_days, allow_stacking)" description="Előfizetés időtartamának beállításai (duration_days, allow_stacking)"
) )
renewal: Optional[RenewalConfigModel] = Field(
default=None,
description="Auto-renewal beállítások (enabled, auto_renew_default, retry_on_failure, max_retry_count, grace_period_days)"
)
ad_policy: Optional[AdPolicyModel] = Field( ad_policy: Optional[AdPolicyModel] = Field(
default=None, default=None,
description="Hirdetési szabályok (show_ads, ad_free_grace_days, max_daily_impressions)" description="Hirdetési szabályok (show_ads, ad_free_grace_days, max_daily_impressions)"
@@ -149,6 +164,104 @@ class SubscriptionRulesModel(BaseModel):
raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.") raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.")
return v.lower() return v.lower()
@model_validator(mode="before")
@classmethod
def handle_legacy_data(cls, data: Any) -> Any:
"""
P0 CRITICAL: Sanitize legacy/incomplete database rows before Pydantic validation.
Legacy rows may have:
- `pricing: {}` (empty dict) → convert to None
- `type: "base"` or missing → convert to None (will be treated as private)
- `lifecycle: {}` (empty dict) → convert to None
- missing `renewal` block → already None (no action needed)
- `affiliate: {}` (empty dict) → convert to None
- `marketing: {}` (empty dict) → convert to None
- `allowances: {}` (empty dict) → convert to None
- `duration: {}` (empty dict) → convert to None
- `ad_policy: {}` (empty dict) → convert to None
- `pricing_zones: {"HU": {}}` (incomplete zone) → skip/remove empty zones
"""
if not isinstance(data, dict):
return data
# ── Sanitize nested dicts: convert empty dicts to None ──
empty_dict_keys = [
"pricing", "pricing_zones", "allowances", "lifecycle",
"duration", "renewal", "ad_policy", "affiliate", "marketing",
]
for key in empty_dict_keys:
val = data.get(key)
if val is not None and isinstance(val, dict) and len(val) == 0:
data[key] = None
# ── Handle legacy type values (e.g. "base", empty string) ──
raw_type = data.get("type")
if raw_type is not None and isinstance(raw_type, str):
raw_lower = raw_type.strip().lower()
if raw_lower not in ("private", "corporate", "addon"):
# Legacy "base" → map to "private" for backward compat
# or just set to None to let the default apply
if raw_lower == "base":
data["type"] = "private"
else:
data["type"] = "private"
elif raw_type is None:
# Missing type → default to private
data["type"] = "private"
# ── Sanitize pricing zones: remove empty zones, ensure valid fields ──
zones = data.get("pricing_zones")
if isinstance(zones, dict):
cleaned_zones = {}
for code, zone_data in zones.items():
if isinstance(zone_data, dict):
# Provide defaults for missing fields
cleaned_zones[code] = {
"monthly_price": zone_data.get("monthly_price", 0) or 0,
"yearly_price": zone_data.get("yearly_price", 0) or 0,
"currency": zone_data.get("currency", "EUR") or "EUR",
"credit_price": zone_data.get("credit_price"),
}
data["pricing_zones"] = cleaned_zones if cleaned_zones else None
# ── Sanitize pricing (legacy): ensure required fields exist ──
legacy_pricing = data.get("pricing")
if isinstance(legacy_pricing, dict):
if "monthly_price" not in legacy_pricing or legacy_pricing.get("monthly_price") is None:
legacy_pricing["monthly_price"] = 0
if "yearly_price" not in legacy_pricing or legacy_pricing.get("yearly_price") is None:
legacy_pricing["yearly_price"] = 0
if "currency" not in legacy_pricing or not legacy_pricing.get("currency"):
legacy_pricing["currency"] = "EUR"
# ── Sanitize lifecycle: ensure at least is_public exists ──
lifecycle = data.get("lifecycle")
if isinstance(lifecycle, dict):
if "is_public" not in lifecycle:
lifecycle["is_public"] = True
# ── Sanitize allowances: ensure all numeric fields have defaults ──
allowances = data.get("allowances")
if isinstance(allowances, dict):
allowances.setdefault("max_vehicles", 0)
allowances.setdefault("max_garages", 0)
allowances.setdefault("max_users", 0)
allowances.setdefault("monthly_free_credits", 0)
# ── Sanitize renewal: ensure all fields have defaults ──
renewal = data.get("renewal")
if isinstance(renewal, dict):
renewal.setdefault("enabled", False)
renewal.setdefault("auto_renew_default", False)
renewal.setdefault("retry_on_failure", True)
renewal.setdefault("max_retry_count", 3)
renewal.setdefault("grace_period_days", 7)
if "renewal_period_days" not in renewal:
renewal["renewal_period_days"] = None
return data
# ── API Response / Request modellek ─────────────────────────────────────────── # ── API Response / Request modellek ───────────────────────────────────────────
@@ -222,6 +335,36 @@ class SubscriptionTierUpdate(BaseModel):
return v.strip().lower() if v else v return v.strip().lower() if v else v
class SubscriptionDetailResponse(BaseModel):
"""
Részletes előfizetés adatok válasz a frontend számára.
Tartalmazza a subscription rekord mezőit, beleértve az auto-renewal adatokat.
"""
tier_id: int
tier_name: str
display_name: str
valid_from: Optional[str] = None
valid_until: Optional[str] = None
is_active: bool
# ── P0 AUTO-RENEWAL FIELDS ──
auto_renew: bool = Field(default=False, description="Auto-renewal engedélyezve")
next_renewal_date: Optional[str] = Field(default=None, description="Következő auto-renewal dátuma")
wallet_auto_deduct: bool = Field(default=False, description="Auto-levonás a walletből megújításkor")
renewal_failure_count: int = Field(default=0, description="Sikertelen megújítási kísérletek száma")
provider_subscription_id: Optional[str] = Field(default=None, description="Külső fizetési gateway subscription ID")
# ── Tier metadata ──
allowances: dict = Field(default_factory=dict)
pricing: dict = Field(default_factory=dict)
duration: Optional[dict] = None
renewal: Optional[dict] = Field(default=None, description="Tier renewal konfiguráció a rules-ból")
ad_policy: Optional[dict] = None
entitlements: list = Field(default_factory=list)
feature_capabilities: dict = Field(default_factory=dict)
extra_allowances: dict = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class SubscriptionTierListResponse(BaseModel): class SubscriptionTierListResponse(BaseModel):
"""Csomagok listázásának válaszformátuma (GET /).""" """Csomagok listázásának válaszformátuma (GET /)."""
total: int total: int

View File

@@ -1,6 +1,6 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py # /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict
from typing import Optional, Any, Dict from typing import Optional, Any, Dict
from datetime import date, datetime from datetime import date, datetime
@@ -43,6 +43,17 @@ class UserResponse(UserBase):
# P0: Real subscription limits from the assigned subscription_tier JSONB rules # P0: Real subscription limits from the assigned subscription_tier JSONB rules
max_vehicles: int = 1 max_vehicles: int = 1
max_garages: int = 1 max_garages: int = 1
# ── P0 Subscription Card Upgrade ──
subscription_display_name: Optional[str] = None
subscription_tier_id: Optional[int] = None
subscription_valid_from: Optional[str] = None
# ── P0 AUTO-RENEWAL FIELDS ──
subscription_auto_renew: bool = False
subscription_next_renewal_date: Optional[str] = None
subscription_wallet_auto_deduct: bool = False
# ── P0 Flip Card ──
active_addons: list = Field(default_factory=list, description="Aktív add-on előfizetések")
# ── End of subscription fields ──
scope_level: str scope_level: str
scope_id: Optional[str] = None scope_id: Optional[str] = None
ui_mode: str = "personal" ui_mode: str = "personal"

View File

@@ -0,0 +1,307 @@
"""
Downgrade Executor — Background service that activates pending downgrades.
Processes subscriptions where:
- pending_tier_id IS NOT NULL (a downgrade was requested)
- valid_until < NOW() (the current paid period has expired)
When both conditions are met:
1. Deactivates the current subscription (is_active = False)
2. Activates the pending tier (creates a new UserSubscription/OrgSubscription)
3. Clears the pending_tier_id and pending_activated_at fields
THOUGHT PROCESS:
- Designed to be called from a CRON job or scheduled task.
- Uses atomic SELECT ... FOR UPDATE SKIP LOCKED to prevent duplicate
activation when multiple workers run concurrently.
- Updates User.subscription_plan and User.subscription_expires_at for
backward compatibility.
- All operations are logged at INFO level for audit trail.
"""
import logging
from datetime import datetime, timedelta
from typing import List, Tuple, Optional
from sqlalchemy import select, update, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.core_logic import (
SubscriptionTier,
UserSubscription,
OrganizationSubscription,
)
from app.models.identity.identity import User
logger = logging.getLogger("downgrade-executor")
class DowngradeExecutor:
"""
Background service that activates pending downgrades after the current
subscription period expires.
Usage:
executor = DowngradeExecutor()
count = await executor.process_pending_downgrades(db)
"""
# ──────────────────────────────────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────────────────────────────────
async def process_pending_downgrades(
self,
db: AsyncSession,
batch_size: int = 50,
) -> Tuple[int, int]:
"""
Process all pending downgrades where the current subscription has expired.
Uses atomic SELECT ... FOR UPDATE SKIP LOCKED to safely handle
concurrent execution.
Args:
db: Database session.
batch_size: Maximum number of downgrades to process in one call.
Returns:
Tuple of (processed_count, error_count).
"""
now = datetime.utcnow()
processed = 0
errors = 0
# ── 1. Process UserSubscription pending downgrades ──────────────
user_subs = await self._fetch_expired_user_pending(db, now, batch_size)
for sub in user_subs:
try:
await self._activate_user_pending(db, sub)
processed += 1
logger.info(
"User pending downgrade activated: user_id=%d "
"from_tier_id=%d to_tier_id=%d",
sub.user_id, sub.tier_id, sub.pending_tier_id,
)
except Exception as e:
errors += 1
logger.exception(
"Failed to activate user pending downgrade: sub_id=%d "
"user_id=%d error=%s",
sub.id, sub.user_id, str(e),
)
# ── 2. Process OrganizationSubscription pending downgrades ─────
org_subs = await self._fetch_expired_org_pending(db, now, batch_size)
for sub in org_subs:
try:
await self._activate_org_pending(db, sub)
processed += 1
logger.info(
"Org pending downgrade activated: org_id=%d "
"from_tier_id=%d to_tier_id=%d",
sub.org_id, sub.tier_id, sub.pending_tier_id,
)
except Exception as e:
errors += 1
logger.exception(
"Failed to activate org pending downgrade: sub_id=%d "
"org_id=%d error=%s",
sub.id, sub.org_id, str(e),
)
if processed > 0 or errors > 0:
logger.info(
"DowngradeExecutor finished: processed=%d errors=%d",
processed, errors,
)
return (processed, errors)
# ──────────────────────────────────────────────────────────────────────────
# Internal: Fetch expired pending subscriptions
# ──────────────────────────────────────────────────────────────────────────
async def _fetch_expired_user_pending(
self,
db: AsyncSession,
now: datetime,
limit: int,
) -> List[UserSubscription]:
"""
Fetch UserSubscription records where:
- pending_tier_id IS NOT NULL
- valid_until < now (expired)
- is_active == True
Uses FOR UPDATE SKIP LOCKED for atomic processing.
"""
stmt = (
select(UserSubscription)
.where(
UserSubscription.pending_tier_id.is_not(None),
UserSubscription.valid_until.is_not(None),
UserSubscription.valid_until < now,
UserSubscription.is_active == True,
)
.order_by(UserSubscription.valid_until.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _fetch_expired_org_pending(
self,
db: AsyncSession,
now: datetime,
limit: int,
) -> List[OrganizationSubscription]:
"""
Fetch OrganizationSubscription records where:
- pending_tier_id IS NOT NULL
- valid_until < now (expired)
- is_active == True
Uses FOR UPDATE SKIP LOCKED for atomic processing.
"""
stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.pending_tier_id.is_not(None),
OrganizationSubscription.valid_until.is_not(None),
OrganizationSubscription.valid_until < now,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.valid_until.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
result = await db.execute(stmt)
return list(result.scalars().all())
# ──────────────────────────────────────────────────────────────────────────
# Internal: Activate pending downgrades
# ──────────────────────────────────────────────────────────────────────────
async def _activate_user_pending(
self,
db: AsyncSession,
current_sub: UserSubscription,
) -> UserSubscription:
"""
Activate a pending user-level downgrade.
1. Deactivates the current subscription
2. Fetches the pending tier to get duration info
3. Creates a new UserSubscription for the pending tier
4. Updates User.subscription_plan for backward compat
5. Clears pending fields on the old subscription
"""
target_tier_id = current_sub.pending_tier_id
if target_tier_id is None:
raise ValueError(f"Subscription {current_sub.id} has no pending_tier_id")
# Fetch the target tier for duration info
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == target_tier_id)
tier_result = await db.execute(tier_stmt)
tier = tier_result.scalar_one_or_none()
if not tier:
raise ValueError(f"Pending tier {target_tier_id} not found")
# Resolve duration from tier.rules
duration_days = 30
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
days = duration_config.get("days", 30)
if isinstance(days, (int, float)) and days > 0:
duration_days = int(days)
now = datetime.utcnow()
valid_until = now + timedelta(days=duration_days)
# Deactivate current subscription
current_sub.is_active = False
# Create new subscription for the pending tier
new_sub = UserSubscription(
user_id=current_sub.user_id,
tier_id=target_tier_id,
valid_from=now,
valid_until=valid_until,
is_active=True,
)
db.add(new_sub)
# Update User.subscription_plan for backward compat
user_stmt = select(User).where(User.id == current_sub.user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if user:
user.subscription_plan = tier.name
user.subscription_expires_at = valid_until
# Clear pending fields on old subscription
current_sub.pending_tier_id = None
current_sub.pending_activated_at = None
await db.flush()
await db.refresh(new_sub)
return new_sub
async def _activate_org_pending(
self,
db: AsyncSession,
current_sub: OrganizationSubscription,
) -> OrganizationSubscription:
"""
Activate a pending org-level downgrade.
Same logic as _activate_user_pending but for org subscriptions.
"""
target_tier_id = current_sub.pending_tier_id
if target_tier_id is None:
raise ValueError(f"Subscription {current_sub.id} has no pending_tier_id")
# Fetch the target tier
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == target_tier_id)
tier_result = await db.execute(tier_stmt)
tier = tier_result.scalar_one_or_none()
if not tier:
raise ValueError(f"Pending tier {target_tier_id} not found")
# Resolve duration
duration_days = 30
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
days = duration_config.get("days", 30)
if isinstance(days, (int, float)) and days > 0:
duration_days = int(days)
now = datetime.utcnow()
valid_until = now + timedelta(days=duration_days)
# Deactivate current
current_sub.is_active = False
# Create new subscription
new_sub = OrganizationSubscription(
org_id=current_sub.org_id,
tier_id=target_tier_id,
valid_from=now,
valid_until=valid_until,
is_active=True,
)
db.add(new_sub)
# Clear pending fields
current_sub.pending_tier_id = None
current_sub.pending_activated_at = None
await db.flush()
await db.refresh(new_sub)
return new_sub

View File

@@ -51,6 +51,7 @@ from app.schemas.commission import (
CommissionDistributionResponse, CommissionDistributionResponse,
) )
from app.services import commission_service from app.services import commission_service
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger("financial-manager") logger = logging.getLogger("financial-manager")
@@ -80,9 +81,11 @@ class PurchaseResult:
currency: str = "EUR", currency: str = "EUR",
gateway: str = "mock", gateway: str = "mock",
gateway_intent_id: Optional[str] = None, gateway_intent_id: Optional[str] = None,
checkout_url: Optional[str] = None,
commission_result: Optional[CommissionDistributionResponse] = None, commission_result: Optional[CommissionDistributionResponse] = None,
error: Optional[str] = None, error: Optional[str] = None,
is_org_subscription: bool = False, is_org_subscription: bool = False,
payment_status: Optional[str] = None,
): ):
self.success = success self.success = success
self.payment_intent_id = payment_intent_id self.payment_intent_id = payment_intent_id
@@ -95,9 +98,11 @@ class PurchaseResult:
self.currency = currency self.currency = currency
self.gateway = gateway self.gateway = gateway
self.gateway_intent_id = gateway_intent_id self.gateway_intent_id = gateway_intent_id
self.checkout_url = checkout_url
self.commission_result = commission_result self.commission_result = commission_result
self.error = error self.error = error
self.is_org_subscription = is_org_subscription self.is_org_subscription = is_org_subscription
self.payment_status = payment_status
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Serialize to a dict for API response.""" """Serialize to a dict for API response."""
@@ -115,6 +120,12 @@ class PurchaseResult:
"gateway_intent_id": self.gateway_intent_id, "gateway_intent_id": self.gateway_intent_id,
"is_org_subscription": self.is_org_subscription, "is_org_subscription": self.is_org_subscription,
} }
# Include checkout_url for payment redirect (simulate_redirect mode)
if self.checkout_url:
result["checkout_url"] = self.checkout_url
# Include payment_status for pending payments
if self.payment_status:
result["payment_status"] = self.payment_status
if self.commission_result: if self.commission_result:
result["commission"] = { result["commission"] = {
"total_commission": self.commission_result.total_commission, "total_commission": self.commission_result.total_commission,
@@ -225,6 +236,89 @@ class FinancialManager:
# ── Step 2: Calculate price ──────────────────────────────────── # ── Step 2: Calculate price ────────────────────────────────────
price = await self._calculate_price(db, tier, region_code, currency) price = await self._calculate_price(db, tier, region_code, currency)
# ══════════════════════════════════════════════════════════════════
# Step 2b: FREE TIER BYPASS & DOWNGRADE DETECTION (Issue #429)
# ══════════════════════════════════════════════════════════════════
#
# THOUGHT PROCESS:
# - Free tiers (price=0) cause PaymentRouter to throw
# ValueError("net_amount pozitív szám kell legyen").
# - Solution: skip PaymentIntent creation entirely for $0 tiers.
# - If downgrade (target tier_level < current): set pending_tier_id.
# - If upgrade/same-level with $0: activate immediately.
# - Paid tiers (price > 0): fall through to normal payment flow.
# ══════════════════════════════════════════════════════════════════
if price <= 0:
logger.info(
"Zero-cost tier detected (price=%.2f). Checking downgrade "
"for user_id=%d tier_id=%d", price, user_id, tier_id,
)
is_down = await SubscriptionService.is_downgrade(
db=db, user_id=user_id,
target_tier_id=tier_id, active_org_id=org_id,
)
if is_down:
# ── DOWNGRADE PATH: set pending_tier_id ──
return await self._handle_downgrade(
db=db, user_id=user_id, tier_id=tier_id,
org_id=org_id, tier=tier, currency=currency,
)
# ── UPGRADE OR SAME-LEVEL: activate immediately ──
# Clear any existing pending downgrade first
await self._clear_pending_downgrade(db, user_id, org_id)
if org_id:
subscription = await self.subscription_activator.activate_org_subscription(
db=db, org_id=org_id, tier_id=tier_id,
duration_days=duration_days,
)
is_org = True
else:
subscription = await self.subscription_activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id,
duration_days=duration_days,
)
is_org = False
commission_result = await self._distribute_commission(
db=db, buyer_user_id=user_id,
transaction_amount=0, region_code=region_code,
)
await db.commit()
logger.info(
"Free tier activation COMPLETED: user_id=%d tier=%s sub_id=%d",
user_id, tier.name, subscription.id,
)
return PurchaseResult(
success=True, subscription_id=subscription.id,
tier_name=tier.name,
valid_from=subscription.valid_from,
valid_until=subscription.valid_until,
amount_paid=0, currency=currency,
gateway="none", is_org_subscription=is_org,
)
# ══════════════════════════════════════════════════════════════════
# PAID TIER FLOW (price > 0)
# ══════════════════════════════════════════════════════════════════
#
# THOUGHT PROCESS:
# The payment gateway may return either:
# a) "completed" status (auto_approve mode) — activate immediately
# b) "requires_action" status (simulate_redirect mode) —
# return a checkout_url; subscription activation is deferred
# until the mock webhook callback is received.
#
# In case (b), the caller (frontend) receives the checkout_url and
# must redirect the user. After the user clicks "Pay Now" on the
# mock page, the webhook callback (POST /billing/mock-payment/callback)
# finalizes the PaymentIntent and activates the subscription.
# ══════════════════════════════════════════════════════════════════
# ── Step 3: Create PaymentIntent ─────────────────────────────── # ── Step 3: Create PaymentIntent ───────────────────────────────
payment_intent = await self._create_payment_intent( payment_intent = await self._create_payment_intent(
db=db, db=db,
@@ -242,15 +336,87 @@ class FinancialManager:
"payment_intent_id": payment_intent.id, "payment_intent_id": payment_intent.id,
"tier_id": tier_id, "tier_id": tier_id,
"user_id": user_id, "user_id": user_id,
"org_id": org_id,
"duration_days": duration_days,
"region_code": region_code,
**(metadata or {}), **(metadata or {}),
}, },
) )
# ── Step 4b: Handle gateway response ──────────────────────────
gateway_status = gateway_result.get("status", "completed")
if gateway_status == "requires_action":
# ════════════════════════════════════════════════════════════
# simulate_redirect mode — defer subscription activation
# ════════════════════════════════════════════════════════════
#
# The gateway tells us the user needs to complete payment
# on an external (mock) checkout page. We:
# 1. Store the gateway intent ID on the PaymentIntent
# 2. Store tier metadata for later webhook processing
# 3. Leave PaymentIntent as PENDING
# 4. Do NOT activate the subscription yet
# 5. Return checkout_url so the frontend can redirect
#
# Subscription activation happens when the mock webhook
# callback is received (see billing.py mock-payment/callback).
payment_intent.stripe_session_id = gateway_result.get("id")
# Store the full purchase context in metadata so the webhook
# callback can complete the activation
payment_intent.meta_data = {
**(payment_intent.meta_data or {}),
"tier_id": tier_id,
"user_id": user_id,
"org_id": org_id,
"duration_days": duration_days,
"region_code": region_code,
"checkout_url": gateway_result.get("checkout_url"),
}
# Flush so the PaymentIntent is persisted before returning
await db.flush()
checkout_url = gateway_result.get("checkout_url")
gateway_intent_id = gateway_result.get("id")
logger.info(
"[MOCK_PAYMENT_REDIRECT] Payment requires user action: "
"user_id=%d tier=%s amount=%.2f checkout_url=%s "
"intent_id=%s",
user_id, tier.name, price, checkout_url, gateway_intent_id,
)
# Clear any existing pending downgrade (user is upgrading)
await self._clear_pending_downgrade(db, user_id, org_id)
await db.commit()
return PurchaseResult(
success=True,
payment_intent_id=payment_intent.id,
tier_name=tier.name,
amount_paid=price,
currency=currency,
gateway=type(self.payment_gateway).__name__,
gateway_intent_id=gateway_intent_id,
checkout_url=checkout_url,
payment_status="PENDING_PAYMENT",
is_org_subscription=(org_id is not None),
)
# ════════════════════════════════════════════════════════════════
# auto_approve mode — immediate activation
# ════════════════════════════════════════════════════════════════
# Mark PaymentIntent as COMPLETED # Mark PaymentIntent as COMPLETED
payment_intent.status = PaymentIntentStatus.COMPLETED payment_intent.status = PaymentIntentStatus.COMPLETED
payment_intent.stripe_session_id = gateway_result.get("id") payment_intent.stripe_session_id = gateway_result.get("id")
payment_intent.completed_at = datetime.utcnow() payment_intent.completed_at = datetime.utcnow()
# Clear any existing pending downgrade on upgrade
await self._clear_pending_downgrade(db, user_id, org_id)
# ── Step 5: Activate subscription ────────────────────────────── # ── Step 5: Activate subscription ──────────────────────────────
if org_id: if org_id:
subscription = await self.subscription_activator.activate_org_subscription( subscription = await self.subscription_activator.activate_org_subscription(
@@ -281,7 +447,7 @@ class FinancialManager:
await db.commit() await db.commit()
logger.info( logger.info(
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f " "Purchase flow COMPLETED (immediate): user_id=%d tier=%s amount=%.2f "
"subscription_id=%d commission_total=%.2f", "subscription_id=%d commission_total=%.2f",
user_id, tier.name, price, user_id, tier.name, price,
subscription.id, subscription.id,
@@ -300,6 +466,7 @@ class FinancialManager:
currency=currency, currency=currency,
gateway=type(self.payment_gateway).__name__, gateway=type(self.payment_gateway).__name__,
gateway_intent_id=gateway_result.get("id"), gateway_intent_id=gateway_result.get("id"),
payment_status="COMPLETED",
commission_result=commission_result, commission_result=commission_result,
is_org_subscription=is_org, is_org_subscription=is_org,
) )
@@ -517,3 +684,161 @@ class FinancialManager:
) )
# Commission failure should not block the purchase # Commission failure should not block the purchase
return None return None
# ──────────────────────────────────────────────────────────────────────────
# Pending Downgrade Helpers (Issue #429)
# ──────────────────────────────────────────────────────────────────────────
async def _handle_downgrade(
self,
db: AsyncSession,
user_id: int,
tier_id: int,
org_id: Optional[int],
tier: SubscriptionTier,
currency: str,
) -> PurchaseResult:
"""
Handle a downgrade request by setting pending_tier_id on the current
active subscription. The downgrade takes effect only after the current
paid period expires.
THOUGHT PROCESS:
- Finds the currently active subscription (user or org level).
- Sets pending_tier_id to the target tier ID.
- Sets pending_activated_at to now (audit trail).
- The actual tier switch is deferred to the DowngradeExecutor service.
- Returns a success PurchaseResult with the CURRENT subscription data.
Args:
db: Database session.
user_id: The user requesting the downgrade.
tier_id: The target SubscriptionTier ID (cheaper/free tier).
org_id: Optional org ID for org-level subscriptions.
tier: The target SubscriptionTier object (for tier_name).
currency: Currency code.
Returns:
PurchaseResult with success=True and current subscription data.
"""
if org_id:
from app.models.core_logic import OrganizationSubscription
stmt = select(OrganizationSubscription).where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
).order_by(OrganizationSubscription.id.desc()).limit(1)
result = await db.execute(stmt)
current_sub = result.scalar_one_or_none()
else:
from app.models.core_logic import UserSubscription
stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
).order_by(UserSubscription.id.desc()).limit(1)
result = await db.execute(stmt)
current_sub = result.scalar_one_or_none()
if not current_sub:
# No active subscription — activate the downgrade immediately
logger.info(
"No active subscription for user_id=%d. Activating downgrade immediately.",
user_id,
)
if org_id:
new_sub = await self.subscription_activator.activate_org_subscription(
db=db, org_id=org_id, tier_id=tier_id,
)
else:
new_sub = await self.subscription_activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id,
)
await db.commit()
return PurchaseResult(
success=True, subscription_id=new_sub.id,
tier_name=tier.name,
valid_from=new_sub.valid_from,
valid_until=new_sub.valid_until,
amount_paid=0, currency=currency,
gateway="none",
is_org_subscription=(org_id is not None),
)
# Set pending tier on existing active subscription
current_sub.pending_tier_id = tier_id
current_sub.pending_activated_at = datetime.utcnow()
await db.flush()
await db.refresh(current_sub)
logger.info(
"Pending downgrade set: %s_id=%d current_tier_id=%d "
"pending_tier_id=%d valid_until=%s",
"org" if org_id else "user",
org_id or user_id,
current_sub.tier_id, tier_id,
current_sub.valid_until,
)
await db.commit()
return PurchaseResult(
success=True,
subscription_id=current_sub.id,
tier_name=tier.name,
valid_from=current_sub.valid_from,
valid_until=current_sub.valid_until,
amount_paid=0,
currency=currency,
gateway="none",
is_org_subscription=(org_id is not None),
)
async def _clear_pending_downgrade(
self,
db: AsyncSession,
user_id: int,
org_id: Optional[int],
) -> None:
"""
Clear any pending downgrade on the user's or org's subscriptions.
Called when an upgrade or new purchase occurs.
THOUGHT PROCESS:
- Scans both active and recently deactivated subscriptions for
pending_tier_id IS NOT NULL.
- Sets pending_tier_id = NULL and pending_activated_at = NULL.
- This ensures that an upgrade overrides a previously scheduled downgrade.
Args:
db: Database session.
user_id: The user ID.
org_id: Optional org ID.
"""
from app.models.core_logic import UserSubscription, OrganizationSubscription
if org_id:
stmt = select(OrganizationSubscription).where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.pending_tier_id.is_not(None),
)
else:
stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.pending_tier_id.is_not(None),
)
result = await db.execute(stmt)
subs_with_pending = result.scalars().all()
for sub in subs_with_pending:
sub.pending_tier_id = None
sub.pending_activated_at = None
logger.debug(
"Cleared pending downgrade on subscription id=%d", sub.id,
)
if subs_with_pending:
logger.info(
"Cleared %d pending downgrade(s) for %s_id=%d",
len(subs_with_pending), "org" if org_id else "user",
org_id or user_id,
)

View File

@@ -33,37 +33,44 @@ class MockPaymentGateway(BasePaymentGateway):
Mock payment gateway for development and testing. Mock payment gateway for development and testing.
Modes: Modes:
- auto_approve (default): All payments succeed immediately. - simulate_redirect (NEW default): Returns a checkout URL for paid tiers.
The frontend redirects to the mock checkout page; after user clicks "Pay Now",
a webhook callback marks the PaymentIntent as COMPLETED.
- auto_approve: All payments succeed immediately (legacy).
- simulate_failure: All payments fail with a configurable error message. - simulate_failure: All payments fail with a configurable error message.
- simulate_timeout: Payments hang until a configurable timeout then succeed. - simulate_timeout: Payments hang until a configurable timeout then succeed.
Usage: Usage:
gateway = MockPaymentGateway(mode="auto_approve") gateway = MockPaymentGateway(mode="simulate_redirect")
result = await gateway.create_intent(Decimal("100.00"), "EUR") result = await gateway.create_intent(Decimal("100.00"), "EUR")
""" """
def __init__( def __init__(
self, self,
mode: str = "auto_approve", mode: str = "simulate_redirect",
failure_message: str = "Mock payment declined (simulated failure)", failure_message: str = "Mock payment declined (simulated failure)",
timeout_seconds: int = 5, timeout_seconds: int = 5,
base_url: str = "http://localhost:8000",
): ):
""" """
Initialize the mock gateway. Initialize the mock gateway.
Args: Args:
mode: Operation mode — "auto_approve", "simulate_failure", or "simulate_timeout". mode: Operation mode — "simulate_redirect" (default), "auto_approve",
"simulate_failure", or "simulate_timeout".
failure_message: Custom error message for simulate_failure mode. failure_message: Custom error message for simulate_failure mode.
timeout_seconds: Simulated processing delay for simulate_timeout mode. timeout_seconds: Simulated processing delay for simulate_timeout mode.
base_url: Base URL for generating mock checkout URLs.
""" """
self.mode = mode self.mode = mode
self.failure_message = failure_message self.failure_message = failure_message
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
self.base_url = base_url
self._processed_intents: Dict[str, Dict[str, Any]] = {} self._processed_intents: Dict[str, Dict[str, Any]] = {}
logger.info( logger.info(
"MockPaymentGateway initialized: mode=%s, timeout=%ds", "MockPaymentGateway initialized: mode=%s, timeout=%ds, base_url=%s",
self.mode, self.timeout_seconds, self.mode, self.timeout_seconds, self.base_url,
) )
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
@@ -80,28 +87,46 @@ class MockPaymentGateway(BasePaymentGateway):
""" """
Create a mock payment intent. Create a mock payment intent.
In auto_approve mode, immediately returns a "completed" intent. Modes:
In simulate_failure mode, raises PaymentGatewayError. - simulate_redirect (default): For paid amounts (>0), returns a checkout URL
In simulate_timeout mode, logs a warning and returns "processing". with status "requires_action". The frontend redirects the user to this URL.
For $0 amounts, falls through to auto_approve.
- auto_approve: Immediately returns a "completed" intent.
- simulate_failure: Raises PaymentGatewayError.
- simulate_timeout: Logs a warning and returns "processing".
THOUGHT PROCESS:
The simulate_redirect mode bridges the gap between "instant success"
and a realistic payment flow. The returned checkout_url points to a
mock page served by our own backend. When the user clicks "Pay Now",
the mock page POSTs to a webhook callback endpoint that finalizes
the PaymentIntent. This allows frontend testing of the full redirect
→ callback → success lifecycle without Stripe.
Args: Args:
amount: The payment amount. amount: The payment amount.
currency: ISO 4217 currency code (default: EUR). currency: ISO 4217 currency code (default: EUR).
metadata: Optional metadata dict. metadata: Optional metadata dict.
**kwargs: Additional parameters (ignored in mock). **kwargs: Supports 'base_url' override for the checkout URL base.
Returns: Returns:
Dict with mock payment intent details. Dict with mock payment intent details:
- simulate_redirect: {id, status: "requires_action", checkout_url, ...}
- auto_approve: {id, status: "completed", ...}
Raises: Raises:
PaymentGatewayError: In simulate_failure mode. PaymentGatewayError: In simulate_failure mode.
""" """
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}" intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
# ── Strict console log for audit trail ──────────────────────────────
logger.info( logger.info(
"Mock create_intent: id=%s amount=%s %s mode=%s", "[MOCK_PAYMENT_REQUEST] Initiating transaction with external gateway "
intent_id, amount, currency, self.mode, "for amount: %s %s, mode=%s, intent_id=%s",
amount, currency, self.mode, intent_id,
) )
# ── simulate_failure: Always fail ───────────────────────────────────
if self.mode == "simulate_failure": if self.mode == "simulate_failure":
logger.warning( logger.warning(
"Mock payment FAILURE: intent=%s reason='%s'", "Mock payment FAILURE: intent=%s reason='%s'",
@@ -109,6 +134,7 @@ class MockPaymentGateway(BasePaymentGateway):
) )
raise PaymentGatewayError(self.failure_message) raise PaymentGatewayError(self.failure_message)
# ── simulate_timeout: Pretend to hang then return "processing" ──────
if self.mode == "simulate_timeout": if self.mode == "simulate_timeout":
logger.warning( logger.warning(
"Mock payment TIMEOUT simulation: intent=%s delay=%ds", "Mock payment TIMEOUT simulation: intent=%s delay=%ds",
@@ -117,19 +143,59 @@ class MockPaymentGateway(BasePaymentGateway):
# In a real scenario we'd sleep; here we just return "processing" # In a real scenario we'd sleep; here we just return "processing"
# so the caller can handle the pending state. # so the caller can handle the pending state.
# ── simulate_redirect: Return a checkout URL for paid tiers ─────────
# For $0 amounts, fall through to auto_approve behaviour.
if self.mode == "simulate_redirect" and amount > 0:
# Use the base_url from kwargs if provided, otherwise use self.base_url
base_url = kwargs.get("base_url", self.base_url)
# Generate the mock checkout page URL — the user will be redirected
# here to complete the payment. After clicking "Pay Now", the mock
# page POSTs to /billing/mock-payment/callback which updates the
# PaymentIntent from PENDING → COMPLETED.
checkout_url = f"{base_url}/api/v1/billing/mock-payment/checkout/{intent_id}"
intent_data = {
"id": intent_id,
"status": "requires_action",
"amount": float(amount),
"currency": currency,
"checkout_url": checkout_url,
"method": "GET", # Frontend opens this URL in a new tab/window
"created_at": datetime.utcnow().isoformat(),
"completed_at": None,
"metadata": metadata or {},
"gateway": "mock",
}
self._processed_intents[intent_id] = intent_data
logger.info(
"[MOCK_PAYMENT_REDIRECT] Checkout URL generated: intent=%s "
"checkout_url=%s amount=%s %s",
intent_id, checkout_url, amount, currency,
)
return intent_data
# ── auto_approve OR $0 amount: Immediate completion ─────────────────
now = datetime.utcnow() now = datetime.utcnow()
intent_data = { intent_data = {
"id": intent_id, "id": intent_id,
"status": "completed" if self.mode == "auto_approve" else "processing", "status": "completed",
"amount": float(amount), "amount": float(amount),
"currency": currency, "currency": currency,
"created_at": now.isoformat(), "created_at": now.isoformat(),
"completed_at": now.isoformat() if self.mode == "auto_approve" else None, "completed_at": now.isoformat(),
"metadata": metadata or {}, "metadata": metadata or {},
"gateway": "mock", "gateway": "mock",
} }
self._processed_intents[intent_id] = intent_data self._processed_intents[intent_id] = intent_data
logger.info(
"Mock payment AUTO-APPROVED: intent=%s amount=%s %s",
intent_id, amount, currency,
)
return intent_data return intent_data
async def verify_payment( async def verify_payment(
@@ -228,9 +294,10 @@ class MockPaymentGateway(BasePaymentGateway):
Change the gateway's operating mode at runtime. Change the gateway's operating mode at runtime.
Args: Args:
mode: "auto_approve", "simulate_failure", or "simulate_timeout". mode: "simulate_redirect", "auto_approve", "simulate_failure",
or "simulate_timeout".
""" """
valid_modes = {"auto_approve", "simulate_failure", "simulate_timeout"} valid_modes = {"simulate_redirect", "auto_approve", "simulate_failure", "simulate_timeout"}
if mode not in valid_modes: if mode not in valid_modes:
raise ValueError( raise ValueError(
f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}" f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}"

View File

@@ -333,6 +333,14 @@ class SubscriptionService:
"valid_from": org_sub.valid_from.isoformat() if org_sub.valid_from else None, "valid_from": org_sub.valid_from.isoformat() if org_sub.valid_from else None,
"valid_until": org_sub.valid_until.isoformat() if org_sub.valid_until else None, "valid_until": org_sub.valid_until.isoformat() if org_sub.valid_until else None,
"is_active": org_sub.is_active, "is_active": org_sub.is_active,
# ── P0 AUTO-RENEWAL FIELDS ──
"auto_renew": org_sub.auto_renew,
"next_renewal_date": org_sub.next_renewal_date.isoformat() if org_sub.next_renewal_date else None,
"wallet_auto_deduct": org_sub.wallet_auto_deduct,
"renewal_failure_count": org_sub.renewal_failure_count,
"provider_subscription_id": org_sub.provider_subscription_id,
"renewal": rules.get("renewal", {}),
# ── Existing fields ──
"allowances": rules.get("allowances", {}), "allowances": rules.get("allowances", {}),
"pricing": rules.get("pricing", {}), "pricing": rules.get("pricing", {}),
"duration": rules.get("duration", {}), "duration": rules.get("duration", {}),
@@ -392,6 +400,14 @@ class SubscriptionService:
"valid_from": user_sub.valid_from.isoformat() if user_sub.valid_from else None, "valid_from": user_sub.valid_from.isoformat() if user_sub.valid_from else None,
"valid_until": user_sub.valid_until.isoformat() if user_sub.valid_until else None, "valid_until": user_sub.valid_until.isoformat() if user_sub.valid_until else None,
"is_active": user_sub.is_active, "is_active": user_sub.is_active,
# ── P0 AUTO-RENEWAL FIELDS ──
"auto_renew": user_sub.auto_renew,
"next_renewal_date": user_sub.next_renewal_date.isoformat() if user_sub.next_renewal_date else None,
"wallet_auto_deduct": user_sub.wallet_auto_deduct,
"renewal_failure_count": user_sub.renewal_failure_count,
"provider_subscription_id": user_sub.provider_subscription_id,
"renewal": rules.get("renewal", {}),
# ── Existing fields ──
"allowances": rules.get("allowances", {}), "allowances": rules.get("allowances", {}),
"pricing": rules.get("pricing", {}), "pricing": rules.get("pricing", {}),
"duration": rules.get("duration", {}), "duration": rules.get("duration", {}),
@@ -471,6 +487,65 @@ class SubscriptionService:
return visible return visible
@staticmethod
async def is_downgrade(
db: AsyncSession,
user_id: int,
target_tier_id: int,
active_org_id: Optional[int] = None,
) -> bool:
"""
Determine if switching to the target tier constitutes a downgrade.
A downgrade is when the target tier's `tier_level` is LESS than the
user's CURRENT tier's `tier_level`. Upgrades (same or higher level)
should be activated immediately; downgrades should be deferred.
THOUGHT PROCESS:
- Uses `get_user_tier()` which resolves the effective tier from
UserSubscription, OrganizationSubscription, or fallback.
- Compares integer `tier_level` (0=free, 1=premium, 2=enterprise).
- Returns True when target_level < current_level (downgrade).
- For equal levels or upgrades, returns False (immediate activation).
Args:
db: Database session.
user_id: The user making the change.
target_tier_id: The SubscriptionTier ID they want to switch to.
active_org_id: Optional org ID to determine subscription context.
Returns:
True if this is a downgrade (should be deferred).
False if this is an upgrade or same-level (immediate activation).
Raises:
ValueError: If the target tier does not exist.
"""
# 1. Get the target tier's level
target_stmt = select(SubscriptionTier.tier_level).where(
SubscriptionTier.id == target_tier_id
)
target_result = await db.execute(target_stmt)
target_level = target_result.scalar_one_or_none()
if target_level is None:
raise ValueError(f"SubscriptionTier with id={target_tier_id} not found")
# 2. Get the current effective tier level via the existing resolver
current_tier_name = await SubscriptionService.get_user_tier(db, user_id)
current_level = SubscriptionService._get_user_level(current_tier_name)
# 3. Compare: downgrade if target < current
is_down = target_level < current_level
logger.info(
"Downgrade check: user_id=%d target_tier_id=%d "
"current_level=%d target_level=%d is_downgrade=%s",
user_id, target_tier_id, current_level, target_level, is_down,
)
return is_down
@staticmethod @staticmethod
def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool: def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool:
""" """

167
docs/UI_STANDARDS.md Normal file
View File

@@ -0,0 +1,167 @@
# 🎨 Service Finder UI Design System v2.1
> **Audit Date:** 2026-07-28
> **Scope:** `frontend_app/` Vue 3 + Tailwind CSS project
> **Principle:** Every component must derive its visual properties from this document.
> **Toolkit:** Tailwind CSS v3 custom theme + `main.css` utility components.
---
## 1. 🖌️ Color Palette
### 1.1 Brand Identity (from `tailwind.config.js` + SVG logo)
| Token | Hex | Tailwind Class | Usage |
|---|---|---|---|
| **sf-blue** | `#004B63` | `bg-sf-blue` / `text-sf-blue` | Primary headers, main CTAs, dashboard cards |
| **sf-accent** | `#008CA4` | `bg-sf-accent` / `text-sf-accent` | Hover states, secondary buttons, links |
| **sf-green** | `#70BC84` | `bg-sf-green` / `text-[#70BC84]` | Progress bars, success badges, positive states |
| **sf-wall** | `#B5DCE3` | `bg-sf-wall` | Light panels, background gradients |
| **logo-dark-blue** | `#306081` | `bg-[#306081]` | Plan card headers, logo primary |
| **logo-teal** | `#418890` | `bg-[#418890]` | Logo secondary elements |
| **logo-green** | `#79B085` | `bg-[#79B085]` | Logo leaf/green elements |
### 1.2 Semantic Colors
| Token | Hex | Tailwind Class | Meaning |
|---|---|---|---|
| **success / emerald** | `#10b981` | `bg-emerald-500` / `text-emerald-600` | Confirmation, active status, savings |
| **warning / amber** | `#f59e0b` | `bg-amber-400` / `text-amber-400` | Expiring soon (≤7 days), caution |
| **danger / red** | `#ef4444` | `bg-red-500` / `text-red-600` | Expired, full capacity, delete actions |
| **info / sky** | `#0284c7` | `bg-sky-600` | Subscription cards, neutral info |
### 1.3 Neutral & Background
| Token | Hex | Tailwind Class | Usage |
|---|---|---|---|
| **dark-bg** | `#04151F` | `bg-[#04151F]` | App body background |
| **dark-lighter** | `#0a2a40` | `bg-[#0a2a40]` | Gradient top |
| **white-solid** | `#ffffff` | `bg-white` | Content cards (light theme) |
| **white-95** | `rgba(255,255,255,0.95)` | `bg-white/95` | Semi-transparent card faces |
| **slate-50** | `#f8fafc` | `bg-slate-50` | Card inner sections (stats, inputs) |
| **slate-200** | `#e2e8f0` | `border-slate-200` | Card inner borders |
| **slate-500** | `#64748b` | `text-slate-500` | Secondary text on light cards |
| **slate-700** | `#334155` | `bg-slate-700` / `text-slate-700` | Header bars, primary text on light |
| **slate-800** | `#1e293b` | `text-slate-800` | Card body text color |
### 1.4 Gradient Presets
| Name | Tailwind | Usage |
|---|---|---|
| **sf-dark-bg** | `radial-gradient(...)` | `body` global background |
| **header-amber** | `bg-gradient-to-r from-amber-600 to-amber-700` | Wallet card header |
| **header-sky** | `bg-gradient-to-r from-sky-600 to-sky-700` | Subscription card header |
| **header-emerald** | `bg-gradient-to-r from-emerald-600 to-emerald-700` | Transactions card header |
| **header-violet** | `bg-gradient-to-r from-violet-600 to-violet-700` | Invoices card header |
| **header-green** (add-on) | `bg-gradient-to-r from-emerald-600 to-emerald-700` | Add-on card headers |
| **btn-premium** | `bg-gradient-to-r from-[#418890] to-[#70BC84]` | Premium buttons hover state |
---
## 2. 📐 Cards
### 2.1 Standard Card (Light Theme)
- **Radius:** Always `rounded-2xl` for cards
- **Shadow:** `shadow-[0_8px_30px_rgb(0,0,0,0.12)]` by default, `shadow-2xl` on hover
- **Hover:** `hover:-translate-y-3 hover:scale-[1.02]` — subtle lift + scale
- **Height:** `h-[350px]` fixed height standard; use `h-auto` for list cards
- **Background:** `bg-white/95` (semi-transparent white) for light theme cards
- **Body text color:** `text-slate-800` inside card bodies
- **Body padding:** `p-4` standard; `p-6` for "lg" variant
- **Header colors:** Use semantic gradients: amber=wallet, sky=subscription, emerald=transactions, violet=invoices
### 2.2 Dark / Glass Card
- Use `bg-white/5` or `bg-white/10` for glass effect
- Always add `backdrop-blur-md` for blur
- Border: `border-white/10` or `border-white/20`
- Text: `text-white/80` or `text-white/60` for secondary
### 2.3 BaseCard Component
Prefab component with `header`, default `body`, `footer` slots. See `BaseCard.vue`.
---
## 3. 🔘 Buttons
| Variant | Tailwind Classes |
|---|---|
| **btn-primary** | `px-4 py-2 bg-sf-blue text-white rounded-lg hover:bg-sf-accent transition-colors` |
| **btn-secondary** | `px-4 py-2 bg-sf-wall text-sf-blue rounded-lg hover:bg-sf-green hover:text-white` |
| **btn-premium** | `rounded-xl bg-white/10 px-6 py-3 font-bold text-white backdrop-blur-md border border-white/20` |
| **btn-card-cta** | `w-full text-center px-4 py-2.5 rounded-xl text-sm font-semibold shadow-md hover:shadow-lg active:scale-[0.98]` |
| **Disabled** | `bg-slate-400 cursor-not-allowed` |
---
## 4. 📝 Typography
- **Font:** `Inter, system-ui, sans-serif`
- **H1:** `text-3xl font-bold text-white`
- **H2:** `text-2xl font-bold text-white`
- **H3:** `text-lg font-bold text-slate-900`
- **H4:** `text-sm font-semibold text-slate-700 uppercase tracking-wider`
- **Body:** `text-sm text-slate-700` (light) / `text-sm text-white/60` (dark)
- **Small:** `text-xs text-slate-500`
---
## 5. 🪟 Modals
- Backdrop: `bg-black/60 backdrop-blur-sm`
- Close on backdrop click: `@click.self="$emit('close')"`
- Max height: `max-h-[90vh]`
- Transition: `modal-fade` 0.2s ease opacity
- Panel: `bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col`
- Header: colored bar with `text-white font-bold text-lg`
- Footer: `bg-slate-50 border-t border-slate-200`
---
## 6. 🧩 Add-on Card Specification (Phase 4)
The add-on cards follow the **same solid card pattern** as base plan cards with a **green-themed header** to visually distinguish them.
### 6.1 Classes
| Element | Classes |
|---|---|
| **Wrapper (selected)** | `bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden border-emerald-500 ring-1 ring-emerald-500/50 transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl` |
| **Wrapper (unselected)** | `bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden border-slate-200 transition-all duration-300 hover:border-slate-400 hover:-translate-y-2 hover:shadow-2xl` |
| **Header** | `h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 w-full shrink-0 flex items-center px-4` |
| **Header text** | `text-white font-bold text-sm tracking-wide` |
| **Body** | `p-4 flex flex-col h-full text-slate-800` |
| **Inner stat badges** | `bg-slate-50 text-slate-700` (match base plan stat boxes) |
| **Price (main)** | `text-xl font-bold text-slate-900` |
| **Price (small)** | `text-slate-500 text-xs` |
| **Toggle (selected)** | `w-full py-2 rounded-xl text-sm font-semibold bg-emerald-600 hover:bg-emerald-700 text-white` |
| **Toggle (unselected)** | `w-full py-2 rounded-xl text-sm font-semibold bg-slate-100 hover:bg-emerald-50 text-slate-700 hover:text-emerald-700 border border-slate-200 hover:border-emerald-300` |
### 6.2 What Changes from Current Glass Style
| Element | Current (broken) | Fix (target) |
|---|---|---|
| **Wrapper bg** | `bg-emerald-900/20` (glass, transparent on garage-bg) | `bg-white/95` (solid white, readable) |
| **Wrapper border** | `border-slate-700` (invisible) | `border-slate-200` (visible) |
| **Text color** | `text-white` (washes out on bg) | `text-slate-800` (solid dark on white) |
| **Secondary text** | `text-white/40` (illegible) | `text-slate-500` (readable) |
| **Toggle (unselected)** | `bg-white/10 text-white/80` (glass) | `bg-slate-100 text-slate-700 border border-slate-200` (solid) |
| **Header** | None (no header bar) | Green gradient header with add-on name |
| **Shadow** | None | `shadow-[0_8px_30px_rgb(0,0,0,0.12)]` |
| **Hover** | None | `hover:-translate-y-2 hover:shadow-2xl` |
| **Stat badges** | `bg-white/10 text-white/50` (invisible) | `bg-slate-50 text-slate-700` (same as base plans) |
---
## 7. ♿ Accessibility Checklist
- Minimum contrast ratio 4.5:1 for body text
- Focus rings on all interactive elements (`focus:ring-2 focus:ring-sf-accent`)
- `aria-label` on icon-only buttons
- Keyboard navigation for modals (Escape to close)
---
*End of UI Design System v2.1*

View File

@@ -0,0 +1,70 @@
# 🏗️ Finance Flip Card & Context-Aware Filter Implementation
**Date:** 2026-07-27
**Author:** Core Developer (Fast Coder)
**Gitea Card:** #424
---
## 📋 Summary
Implementation of two features for the Finance module plus an admin memory fix:
| # | Feature | Status |
|---|---------|--------|
| 1 | **CSS 3D Flipping Card** — Card 2 in `FinanceMainView.vue` | ✅ Done |
| 2 | **Context-Aware Package Filter** — Enhanced `SubscriptionPlansView.vue` | ✅ Done |
| 3 | **Admin Memory Fix**`NODE_OPTIONS=--max-old-space-size=4096`, cache purge, restart | ✅ Done |
---
## 🗄️ Backend Changes
### File: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py)
1. **`_build_user_response()`** — Added `created_at` field from `User.created_at` (after line 131)
2. **`read_users_me()`** — Added `subscription_valid_from` variable initialization (line 260)
3. **`read_users_me()`** — Extracted `valid_from` in both org branch (after line 275) and personal branch (after line 290)
4. **`read_users_me()`** — New add-on resolution query block (~35 lines) resolving active `OrganizationSubscription` or `UserSubscription` records where `SubscriptionTier.type == 'addon'`
5. **`read_users_me()`** — Added `subscription_valid_from` and `active_addons` to response dict (after line 308)
### Contract: `/auth/me` response now includes:
- `created_at` — ISO 8601 datetime of user registration
- `subscription_valid_from` — ISO 8601 of current subscription start
- `active_addons` — Array of `{tier_id, display_name, valid_from, valid_until, is_active}`
---
## 🖥️ Frontend Changes
### File: [`frontend_app/src/stores/auth.ts`](frontend_app/src/stores/auth.ts)
- Added `user_registration_date`, `subscription_valid_from`, `active_addons` to `UserProfile` interface
### File: [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue)
- **Template:** Card 2 replaced with flip card structure (front face + back face)
- **Script:** Added `ref` import, `isFlipped` state, `toggleFlip()` function, computed properties (`userRegistrationDate`, `subscriptionStartDate`, `activeAddons`), helper functions (`formatAddonDate`, `isAddonExpired`)
- **CSS:** Added scoped 3D flip CSS with `perspective: 1000px`, `transform-style: preserve-3d`, `backface-visibility: hidden`, `rotateY(180deg)` on `.is-flipped`
- **CTA button** uses `@click.stop` to prevent flip trigger
### File: [`frontend_app/src/views/SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue)
- Enhanced `filteredPlans` computed to check `plan.rules?.type` JSONB first, then fall back to name prefix matching
- Supports: `corporate`, `business`, `private`, `consumer` rules types
### 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)
- Added 7 new keys under `subscription:` block
---
## 🐳 Admin Memory Fix
- **File:** [`docker-compose.yml:293`](docker-compose.yml:293) — `NODE_OPTIONS=--max-old-space-size=4096` was **already configured**
- Purged `.nuxt/` cache via `docker compose exec sf_admin_frontend rm -rf /app/.nuxt/*`
- Restarted container: `docker compose restart sf_admin_frontend`
---
## ✅ Validation
- `sync_engine.py` — ✅ 1307 OK, 0 Fixed, 0 Shadow — **rendszer tökéletesen szinkronban van**
- Backend changes compile without syntax errors
- All i18n keys present in both locales

View File

@@ -0,0 +1,181 @@
# 📦 Subscription Card Upgrade — Részletes Elemzés
**Date:** 2026-07-27
**Author:** System Architect
**Gitea Card:** #423
**Blueprint:** [`plans/logic_spec_subscription_card_upgrade.md`](../plans/logic_spec_subscription_card_upgrade.md)
---
## 1. Jelenlegi Állapot (As-Is)
### 1.1 A Card 2 a FinanceMainView-ban
A [`FinanceMainView.vue`](../frontend_app/src/views/FinanceMainView.vue) négy kártyát jelenít meg a `/finance` oldalon. A második kártya ("📦 Csomagok") jelenleg így néz ki:
- **Adatforrás:** [`authStore.user?.subscription_plan`](../frontend_app/src/stores/auth.ts:62) — egy nyers string, pl. `"private_pro_v1"`
- **Megjelenítés:** A nyers slug jelenik meg a kártyán, minden formázás nélkül
- **CTA:** Nincs dinamikus gomb, csak a teljes kártya kattintható (átnavigál `/finance/packages`-re)
```typescript
// Jelenlegi adat forrása (FinanceMainView.vue:249-252)
const subscriptionPlanName = computed(() => {
return authStore.user?.subscription_plan || '—'
})
```
### 1.2 A Backend API (/auth/me)
A [`read_users_me()`](../backend/app/api/v1/endpoints/users.py:147) végpont **már feloldja** a `SubscriptionTier` rekordot a `max_vehicles` és `max_garages` limit meghatározásához (lines 206-253), de:
-**Nem** adja vissza a `SubscriptionTier.rules["display_name"]` JSONB mezőt
-**Nem** adja vissza a `UserSubscription.valid_until` / `OrganizationSubscription.valid_until` lejárati dátumot
-**Nem** adja vissza a `subscription_tier_id`-t
### 1.3 Mi Működik Már Most?
A [`SubscriptionStatusWidget.vue`](../frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue) és a [`SubscriptionInfoModal.vue`](../frontend_app/src/components/subscription/SubscriptionInfoModal.vue) már rendelkezik a gazdagabb funkcionalitással (járműhasználati progress bar, lejárati dátum), de ezek sötét témájú komponensek, és a FinanceMainView világos témájú Card 2-jében nem használhatók közvetlenül.
---
## 2. Adatbázis Oldal (What Exists)
### 2.1 `system.subscription_tiers`
A [`SubscriptionTier`](../backend/app/models/core_logic.py:68) modell JSONB `rules` mezője tartalmazza:
```json
{
"display_name": "Privát Pro",
"allowances": { "max_vehicles": 3, "max_garages": 1 },
"pricing": { "monthly_price": 9.99, "yearly_price": 99.99, "currency": "EUR" },
"duration": { "days": 30 },
"type": "private",
"lifecycle": { "is_public": true },
"marketing": { "badge": "Pro", "subtitle": "..." }
}
```
A `display_name` mező létezik a JSONB-ben, de jelenleg nem jut el a frontendig a `/auth/me`-n keresztül.
### 2.2 `finance.user_subscriptions` és `finance.org_subscriptions`
Mindkét tábla tartalmazza:
- `tier_id` → FK a `system.subscription_tiers`-re
- `valid_from` → Érvényesség kezdete
- `valid_until` → Lejárat dátuma (None = nincs lejárat)
- `is_active` → Aktív-e a subscription
A `SubscriptionService.get_user_subscription_details()` és `get_org_subscription_details()` metódusok már visszaadják ezeket az adatokat, de ezek a `GET /subscriptions/my` végpontokon keresztül érhetők el, nem a `/auth/me`-n.
---
## 3. A Terv (To-Be)
### 3.1 Adatfolyam
```
/auth/me
├─ subscription_plan: "private_pro_v1" (már most is)
├─ subscription_display_name: "Privát Pro" (ÚJ)
├─ subscription_expires_at: "2026-12-31T..." (ÚJ)
├─ subscription_tier_id: 14 (ÚJ)
├─ max_vehicles: 3 (már most is)
└─ max_garages: 1 (már most is)
authStore.user
FinanceMainView Card 2
┌─────────────────────────────────┐
│ 📦 Előfizetési Csomagok [Aktív] │
│ │
│ ┌─────────────────────────┐ │
│ │ Aktív │ │
│ │ Privát Pro │ │
│ └─────────────────────────┘ │
│ │
│ Lejárat: 2026.12.31 │
│ │
│ 🚗 jármű: ████████░░ 3 / 5 │
│ │
│ [ Csomag Kezelése ] │
└─────────────────────────────────┘
```
### 3.2 Backend Változások (1 fájl)
**Fájl:** [`users.py`](../backend/app/api/v1/endpoints/users.py) — `read_users_me()` függvény
A már meglévő `SubscriptionTier` feloldási logika UTÁN (lines 206-253) hozzáadunk egy új blokkot, amely:
1. Lekérdezi a `OrganizationSubscription` vagy `UserSubscription` rekordot (attól függően, hogy Corporate vagy Personal módban van a user)
2. Kiolvassa a `valid_until` értéket
3. Kiolvassa a már korábban feloldott `tier.rules["display_name"]` értéket
4. Hozzáadja ezeket a `response_data` dict-hez
### 3.3 Frontend Változások (4 fájl)
| Fájl | Változás |
|------|----------|
| [`auth.ts`](../frontend_app/src/stores/auth.ts) | `UserProfile` interface bővítése `subscription_display_name` és `subscription_tier_id` mezőkkel |
| [`FinanceMainView.vue`](../frontend_app/src/views/FinanceMainView.vue) | Card 2 template és computed property-k teljes cseréje |
| [`hu.ts`](../frontend_app/src/i18n/hu.ts) | `renewNow`, `upgradePlan` i18n kulcsok |
| [`en.ts`](../frontend_app/src/i18n/en.ts) | `renewNow`, `upgradePlan` i18n kulcsok |
---
## 4. Kockázatok és Fallback-ek
| Kockázat | Kezelés |
|----------|---------|
| A `display_name` null lehet régi tier rekordoknál | Fallback lánc: display_name → slug→human map → "Ingyenes" / "—" |
| A `valid_until` None lehet (élettartam előfizetés) | A lejárati blokk csak `v-if="formattedExpiry"` esetén jelenik meg |
| A `vehicleStore.vehicles` nincs betöltve | `onMounted`-ban fetch-eljük, ha szükséges |
| Corporate vs. Personal mód routing | A `ctaRoute` computed kezeli mindkét esetet |
---
## 5. Kapcsolódó Fájlok Térképe
```
backend/
app/
models/
core_logic.py ← SubscriptionTier, UserSubscription, OrganizationSubscription
api/v1/endpoints/
users.py ← /auth/me — IDE KELL az új mezőket hozzáadni
services/
subscription_service.py ← SubscriptionService (már van get_user_subscription_details)
frontend_app/src/
views/
FinanceMainView.vue ← Card 2 — EZT ÍRJUK ÁT
FinancePackagesView.vue ← /finance/packages (SubscriptionStatusWidget-et használ)
components/
dashboard/
SubscriptionStatusWidget.vue ← Sötét témájú, gazdag widget (referencia)
subscription/
SubscriptionInfoModal.vue ← Modal részletes infóval (referencia)
stores/
auth.ts ← UserProfile interface bővítése
vehicle.ts ← vehicleStore (már importálva más komponensekben)
i18n/
hu.ts ← Magyar fordítások
en.ts ← Angol fordítások
```
---
## 6. Ellenőrző Lista a Code Mode számára
- [ ] `users.py`: `subscription_display_name`, `subscription_expires_at`, `subscription_tier_id` feloldása
- [ ] `auth.ts`: `UserProfile` interface bővítése
- [ ] `FinanceMainView.vue`: Card 2 template csere (display_name, expiry, vehicle bar, CTA)
- [ ] `FinanceMainView.vue`: Computed property-k hozzáadása
- [ ] `FinanceMainView.vue`: `useVehicleStore` import és inicializálás
- [ ] `hu.ts`: `renewNow`, `upgradePlan` kulcsok
- [ ] `en.ts`: `renewNow`, `upgradePlan` kulcsok
- [ ] `sync_engine.py` futtatása (nincs séma változás, de ellenőrzés)
- [ ] Vizuális teszt: Card 2 helyes megjelenés ellenőrzése
- [ ] Funkcionális teszt: CTA gomb navigáció ellenőrzése

View File

@@ -0,0 +1,119 @@
# SubscriptionInfoModal planDisplayName Bug Analysis
**Date:** 2026-07-28
**Issue:** #433 - P0: Avatar Menu Subscription Modal - planDisplayName bugfix & DB audit
---
## 1. Database Investigation: admin@profibot.hu
### User Profile
| Field | Value |
|-------|-------|
| `id` | 2 |
| `email` | admin@profibot.hu |
| `role` | ADMIN |
| `subscription_plan` | private_vip_v1 |
| `subscription_expires_at` | 2026-08-27 16:36:24+00 |
| `is_vip` | true |
| `scope_level` | system |
| `scope_id` | 67 |
| `ui_mode` | personal |
### Organization Memberships (3 orgs)
| org_id | role | name | type | status | base_asset_limit |
|--------|------|------|------|--------|-------------------|
| 1 | ADMIN | Profibot Tester - Privat Garazs (#28) | individual | active | 100 |
| 67 | OWNER | User Admin - Privat Garazs (#2) | individual | active | 100 |
| 57 | OWNER | Teszt Autoszerviz Kft. | service_provider | pending_verification | 3 |
### Vehicle Distribution (vehicle.assets via current_organization_id)
| org_id | Count | Sample plates |
|--------|-------|---------------|
| 1 | 19 | TEST-API-999, TEST-123, DRAFT-456, TEST-999, DRAFT-999, TEST-888, DRAFT-888, TEST-777 |
| 67 | 3 | TEST-API-01 (archived), ABB112 (archived), ABC-123 (archived) |
| 57 | 0 | No vehicles |
### Key Finding: No Duplication, No Data Corruption
Vehicles are NOT duplicated across organizations. Each org has distinct vehicles. The perception of "azonos adatok" (same data) likely arises because:
- Org 1 has 19 test vehicles (TEST-XXX, DRAFT-XXX pattern) making it appear bloated
- Org 67 has only 3 archived vehicles
- The KEP 3 showing "private_pro_v1" is the UI bug, not a data issue (admin's actual plan is private_vip_v1)
---
## 2. Root Cause: planDisplayName in SubscriptionInfoModal.vue
### Bug Location
`SubscriptionInfoModal.vue:175-179` — the `planDisplayName` computed property:
```typescript
const planDisplayName = computed(() => {
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
return plan // BUG: returns raw slug like "private_vip_v1"
})
```
### Why It's Broken
The backend `_resolve_subscription_data()` in `users.py:45-191` already:
1. Queries the `SubscriptionTier` assigned to the user/org
2. Extracts `display_name` from `tier.rules` JSONB
3. Returns it as `subscription_display_name` in `/auth/me` and `/users/me`
The `UserProfile` TypeScript interface in `auth.ts:68` already declares:
```typescript
subscription_display_name?: string | null
```
The `SubscriptionInfoModal` ignores this field completely.
### Reference Fix (Already Implemented in SubscriptionStatusWidget)
`SubscriptionStatusWidget.vue:124-150` has the correct 3-level fallback:
1. `authStore.user?.subscription_display_name` (backend-resolved human name)
2. Org-level `subscription_display_name` (corporate mode)
3. Static slug-to-human mapping (fallback)
4. Raw plan name (ultimate fallback)
### Additional Data Binding Verification
| Computed | Current Code | Status |
|----------|-------------|--------|
| maxVehicles | `authStore.user?.max_vehicles ?? 1` | CORRECT |
| maxGarages | `authStore.user?.max_garages ?? 1` | CORRECT |
| vehicleCount | `vehicleStore.vehicles.length` | CORRECT |
| garageCount | `authStore.myOrganizations?.length ?? 0` | Context-dependent |
| vehiclePercent/garagePercent | `count / max * 100` | CORRECT |
---
## 3. Fix Specification
### Required Change
Replace `planDisplayName` computed in `SubscriptionInfoModal.vue:175-179` with the same 3-level fallback logic used by `SubscriptionStatusWidget.vue:124-150`.
### New Code
```typescript
const planDisplayName = computed(() => {
// Priority 1: Backend-supplied human-readable name
const displayName = authStore.user?.subscription_display_name
if (displayName) return displayName
// Priority 2: Slug-to-human mapping fallback
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
const slugToHuman: Record<string, string> = {
'private_basic_v1': 'Privat Alap',
'private_pro_v1': 'Privat Pro',
'private_vip_v1': 'Privat VIP',
'corp_free_v1': 'Ceges Ingyenes',
'corp_basic_v1': 'Ceges Alap',
'corp_pro_v1': 'Ceges Pro',
}
if (slugToHuman[plan]) return slugToHuman[plan]
// Priority 3: Raw plan name as ultimate fallback
return plan
})
```

View File

@@ -0,0 +1,142 @@
# 🔍 Subscription Limit Data Binding Bug — Root Cause Analysis
**Dátum:** 2026-07-28
**Issue:** #431 (Gitea)
**Súlyosság:** P1 — A felhasználó rossz limiteket lát a frontenden
---
## 1. A Felhasználó Által Jelentett Hiba
A frontend "Előfizetés Információ" modális ablakon (SubscriptionStatusWidget) és a FinanceMainView Card 2-n a következő látszik:
- **Jármű Limit:** 1/1
- **Garázs Limit:** 2/1
Egy Pro/VIP csomag esetén a valós limit `max_vehicles=3` (Pro) vagy `max_vehicles=10` (VIP) lenne.
---
## 2. Adatbázis Réteg — Valós Állapot
| Tábla | Rekord | Kulcs mező | Érték |
|---|---|---|---|
| `identity.users` | id=2 (admin@profibot.hu) | `subscription_plan` | `private_vip_v1` |
| `identity.users` | id=2 | `scope_id` | `67` |
| `identity.users` | id=2 | `scope_level` | `system` |
| `identity.users` | id=2 | `ui_mode` | `personal` |
| `finance.user_subscriptions` | id=34 | `tier_id` | **15** (VIP) ✅ AKTÍV |
| `finance.user_subscriptions` | id=33 | `tier_id` | 14 (Pro) ❌ inaktív |
| `finance.org_subscriptions` | org_id=67 | — | **NINCS REKORD** |
| `system.subscription_tiers` | id=14 (private_pro_v1) | `allowances.max_vehicles` | 3 |
| `system.subscription_tiers` | id=14 | `allowances.max_garages` | 1 |
| `system.subscription_tiers` | id=14 | `display_name` | **NULL** ❌ |
| `system.subscription_tiers` | id=15 (private_vip_v1) | `allowances.max_vehicles` | 10 |
| `system.subscription_tiers` | id=15 | `allowances.max_garages` | 2 |
| `system.subscription_tiers` | id=15 | `display_name` | "Privát VIP" ✅ |
---
## 3. ROOT CAUSE #1 (Elsődleges) — Hibás Vezérlési Logika
**Fájl:** [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py), `read_users_me()` függvény, sorok 212254.
### A problémás kódszakasz (pszeudokód):
```python
if active_org_id is not None: # ← scope_id=67 → active_org_id=67 → IGAZ
# 1. Org subscription keresés
tier = org_sub_query(scalar_one_or_none)
if tier and tier.rules: # ← NINCS org subscription (üres finance.org_subscriptions org_id=67-re)
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
else:
# 2. Fallback: Organization.base_asset_limit
org = select(Organization).where(id == active_org_id)
max_vehicles = org.base_asset_limit or 1 # ← 1-et ad!
max_garages = 1 # ← 1-et ad!
# ❌ HIBA: Soha nem nézi meg a user_subscriptions táblát!
else:
# 3. Personal mode: user subscription keresés
# ← SOHA NEM FUT LE, mert active_org_id != None
tier = user_sub_query(scalar_one_or_none)
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1)) # ← 10 lenne!
max_garages = int(allowances.get("max_garages", 1)) # ← 2 lenne!
```
### Miért hibás?
A user-nek van `scope_id=67`-e (egy szervezet ID), de az adott szervezethez **nincs org subscription** rekord. A felhasználónak viszont van **aktív user subscription**-e a VIP tier-re (tier_id=15, max_vehicles=10, max_garages=2).
A kód hibásan feltételezi, hogy ha van `active_org_id`, akkor:
1. vagy van org subscription (→ abból olvassa a limiteket),
2. vagy nincs (→ `Organization.base_asset_limit` fallback, ami 1).
**Soha nem esik át a user subscription ágra**, mert `active_org_id is not None`.
### Hatás
A `GET /auth/me` válaszban:
```json
{
"max_vehicles": 1, // ← 10 helyett
"max_garages": 1, // ← 2 helyett
"subscription_display_name": null // ← mert a tier változó None
}
```
---
## 4. ROOT CAUSE #2 (Másodlagos) — private_pro_v1 display_name NULL
**Tábla:** `system.subscription_tiers` id=14
**Mező:** `rules->>'display_name'` = NULL
A seederben (`seed_packages.py:131`) a `display_name` "Privát Pro"-ként van definiálva, de az éles adatbázisban NULL. Valószínűleg egy korábbi migráció vagy kézi módosítás írta felül.
Ez jelenleg nem okoz közvetlen hibát a felhasználónál (mert ő VIP-n van), de a Pro csomagra váltó felhasználóknál a display_name üres lenne.
---
## 5. Javítási Terv
### 5.1 Backend Fix (`users.py`)
A `read_users_me()` függvényben a fallback láncot ki kell egészíteni:
```
if active_org_id is not None:
1. Org subscription keresés (MEGLÉVŐ)
2. Ha nincs → User subscription keresés (ÚJ!)
3. Ha user subscription sincs → Organization.base_asset_limit (MEGLÉVŐ fallback)
else:
4. User subscription keresés (MEGLÉVŐ)
```
Ugyanez a logika kell a `subscription_valid_until` / `subscription_tier_id` feloldására is (sorok 267308), valamint a `subscription_display_name` feloldására (sor 311312).
### 5.2 DB Fix
```sql
UPDATE system.subscription_tiers
SET rules = jsonb_set(rules, '{display_name}', '"Privát Pro"')
WHERE id = 14 AND rules->>'display_name' IS NULL;
```
### 5.3 Frontend
A frontend komponensek (`FinanceMainView.vue`, `SubscriptionStatusWidget.vue`) már helyesen olvassák az `authStore.user?.max_vehicles` és `authStore.user?.max_garages` értékeket. A backend fix után automatikusan a helyes értékek jelennek meg.
---
## 6. Érintett Fájlok
| Fájl | Változtatás |
|---|---|
| `backend/app/api/v1/endpoints/users.py` | `read_users_me()`: fallback lánc javítása |
| Adatbázis (SQL) | `private_pro_v1` display_name beállítása |
| `frontend_app/src/views/FinanceMainView.vue` | Nincs változtatás szükséges (már a helyes mezőket olvassa) |
| `frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue` | Nincs változtatás szükséges |

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1785068325212} {"id":"dev","timestamp":1785166100527}

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1785068325212,"prerendered":[]} {"id":"dev","timestamp":1785166100527,"prerendered":[]}

View File

@@ -1,5 +1,5 @@
{ {
"date": "2026-07-26T12:18:53.544Z", "date": "2026-07-27T15:28:28.473Z",
"preset": "nitro-dev", "preset": "nitro-dev",
"framework": { "framework": {
"name": "nuxt", "name": "nuxt",
@@ -11,7 +11,7 @@
"dev": { "dev": {
"pid": 19, "pid": 19,
"workerAddress": { "workerAddress": {
"socketPath": "\u0000nitro-worker-19-1-1-8775.sock" "socketPath": "\u0000nitro-worker-19-1-1-1764.sock"
} }
} }
} }

View File

@@ -1,8 +1,8 @@
/// <reference types="@pinia/nuxt" />
/// <reference types="@nuxtjs/i18n" />
/// <reference types="@nuxt/devtools" />
/// <reference types="@nuxtjs/tailwindcss" /> /// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@nuxt/telemetry" /> /// <reference types="@nuxt/telemetry" />
/// <reference types="@pinia/nuxt" />
/// <reference types="@nuxt/devtools" />
/// <reference types="@nuxtjs/i18n" />
/// <reference path="types/nitro-layouts.d.ts" /> /// <reference path="types/nitro-layouts.d.ts" />
/// <reference path="types/builder-env.d.ts" /> /// <reference path="types/builder-env.d.ts" />
/// <reference types="nuxt" /> /// <reference types="nuxt" />

View File

@@ -1,4 +1,4 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/26/2026, 12:18:45 PM // generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/28/2026, 12:21:03 PM
import "@nuxtjs/tailwindcss/config-ctx" import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger"; import configMerger from "@nuxtjs/tailwindcss/merger";

View File

@@ -704,6 +704,113 @@
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.feature_capabilities_hint') }}</p> <p class="text-xs text-slate-500 mt-1">{{ $t('packages.feature_capabilities_hint') }}</p>
<p v-if="featureCapabilitiesError" class="text-xs text-red-400 mt-1">{{ featureCapabilitiesError }}</p> <p v-if="featureCapabilitiesError" class="text-xs text-red-400 mt-1">{{ featureCapabilitiesError }}</p>
</div> </div>
<!-- TAB 4: Auto-Renewal Rules -->
<div v-if="activeTab === 'renewal'" class="space-y-6">
<!-- Renewal Enabled Toggle -->
<div class="bg-slate-700/30 rounded-xl p-5">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="text-sm font-semibold text-slate-200">🔄 {{ $t('packages.renewal_title') || 'Auto-Renewal' }}</h4>
<p class="text-xs text-slate-400 mt-1">{{ $t('packages.renewal_subtitle') || 'Configure automatic subscription renewal rules for this package.' }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.renewal_enabled"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-slate-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
<span class="ms-3 text-sm text-slate-300">{{ form.renewal_enabled ? 'Enabled' : 'Disabled' }}</span>
</label>
</div>
</div>
<!-- Renewal Settings (shown only when enabled) -->
<div v-if="form.renewal_enabled" class="space-y-5">
<!-- Auto-Renew Default Toggle -->
<div class="bg-slate-700/30 rounded-xl p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-slate-200">{{ $t('packages.renewal_auto_default') || 'Auto-Renew by Default' }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('packages.renewal_auto_default_hint') || 'New subscriptions will have auto-renew enabled by default.' }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.renewal_auto_renew_default"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-slate-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
<span class="ms-3 text-sm text-slate-300">{{ form.renewal_auto_renew_default ? 'Yes' : 'No' }}</span>
</label>
</div>
</div>
<!-- Renewal Period (grid row) -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.renewal_period') || 'Renewal Period (days)' }}</label>
<input
v-model.number="form.renewal_period_days"
type="number"
min="1"
max="365"
:placeholder="$t('packages.renewal_period_placeholder') || 'Leave empty = use duration.days'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.renewal_period_hint') || 'If empty, the subscription duration.days is used.' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.renewal_grace_period') || 'Grace Period (days)' }}</label>
<input
v-model.number="form.renewal_grace_period_days"
type="number"
min="0"
max="90"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.renewal_grace_period_hint') || 'Days after expiry before subscription suspension.' }}</p>
</div>
</div>
<!-- Retry Settings (grid row) -->
<div class="bg-slate-700/30 rounded-xl p-5">
<h4 class="text-sm font-semibold text-slate-200 mb-4">{{ $t('packages.renewal_retry_title') || 'Retry on Failure' }}</h4>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-300">{{ $t('packages.renewal_retry') || 'Retry on Failure' }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('packages.renewal_retry_hint') || 'Retry failed payment attempts automatically.' }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.renewal_retry_on_failure"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-slate-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
<span class="ms-3 text-sm text-slate-300">{{ form.renewal_retry_on_failure ? 'Yes' : 'No' }}</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.renewal_max_retry') || 'Max Retry Count' }}</label>
<input
v-model.number="form.renewal_max_retry_count"
type="number"
min="0"
max="20"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
</div>
</div>
<!-- Legacy data notice (empty state when disabled) -->
<div v-if="!form.renewal_enabled" class="bg-slate-700/20 rounded-xl p-5 text-center">
<p class="text-sm text-slate-400">{{ $t('packages.renewal_disabled_hint') || 'Auto-renewal is disabled for this package. Toggle the switch above to enable it.' }}</p>
</div>
</div>
</div> </div>
</div> </div>
@@ -825,6 +932,14 @@ interface SubscriptionTier {
is_public?: boolean is_public?: boolean
available_until?: string | null available_until?: string | null
} | null } | null
renewal?: {
enabled?: boolean
auto_renew_default?: boolean
renewal_period_days?: number | null
retry_on_failure?: boolean
max_retry_count?: number
grace_period_days?: number
} | null
} }
is_custom: boolean is_custom: boolean
tier_level: number tier_level: number
@@ -855,6 +970,13 @@ interface PackageForm {
trial_days_on_signup: number trial_days_on_signup: number
feature_capabilities_json: string feature_capabilities_json: string
pricing_zones: Record<string, { monthly_price: number; yearly_price: number; credit_price: number; currency: string }> pricing_zones: Record<string, { monthly_price: number; yearly_price: number; credit_price: number; currency: string }>
// ── P0 Phase E: Auto-renewal rules ──
renewal_enabled: boolean
renewal_auto_renew_default: boolean
renewal_period_days: number | null
renewal_retry_on_failure: boolean
renewal_max_retry_count: number
renewal_grace_period_days: number
} }
// ── Tab Configuration ────────────────────────────────────────────── // ── Tab Configuration ──────────────────────────────────────────────
@@ -863,6 +985,7 @@ const tabs = [
{ key: 'basic', label: '📋 Basic Info' }, { key: 'basic', label: '📋 Basic Info' },
{ key: 'pricing', label: '💰 Pricing' }, { key: 'pricing', label: '💰 Pricing' },
{ key: 'features', label: '⚡ Features' }, { key: 'features', label: '⚡ Features' },
{ key: 'renewal', label: '🔄 Auto-Renewal' },
] ]
const activeTab = ref('basic') const activeTab = ref('basic')
@@ -956,7 +1079,14 @@ const defaultForm: PackageForm = {
is_default_fallback: false, is_default_fallback: false,
trial_days_on_signup: 0, trial_days_on_signup: 0,
feature_capabilities_json: '{\n "ai_analysis": false,\n "priority_support": false,\n "api_access": false\n}', feature_capabilities_json: '{\n "ai_analysis": false,\n "priority_support": false,\n "api_access": false\n}',
pricing_zones: {} pricing_zones: {},
// ── P0 Phase E: Auto-renewal defaults ──
renewal_enabled: false,
renewal_auto_renew_default: false,
renewal_period_days: null,
renewal_retry_on_failure: true,
renewal_max_retry_count: 3,
renewal_grace_period_days: 7,
} }
const form = reactive<PackageForm>({ ...defaultForm }) const form = reactive<PackageForm>({ ...defaultForm })
@@ -1181,6 +1311,15 @@ function openEditModal(pkg: SubscriptionTier) {
// Load feature capabilities as JSON string // Load feature capabilities as JSON string
form.feature_capabilities_json = JSON.stringify(pkg.feature_capabilities || {}, null, 2) form.feature_capabilities_json = JSON.stringify(pkg.feature_capabilities || {}, null, 2)
// ── P0 Phase E: Load auto-renewal rules ──
const renewalFromRules = rules.renewal || {}
form.renewal_enabled = renewalFromRules.enabled ?? false
form.renewal_auto_renew_default = renewalFromRules.auto_renew_default ?? false
form.renewal_period_days = renewalFromRules.renewal_period_days ?? null
form.renewal_retry_on_failure = renewalFromRules.retry_on_failure ?? true
form.renewal_max_retry_count = renewalFromRules.max_retry_count ?? 3
form.renewal_grace_period_days = renewalFromRules.grace_period_days ?? 7
showModal.value = true showModal.value = true
} }
@@ -1301,6 +1440,15 @@ async function savePackage() {
lifecycle: { lifecycle: {
is_public: true, is_public: true,
}, },
// ── P0 Phase E: Auto-renewal rules ──
renewal: {
enabled: form.renewal_enabled,
auto_renew_default: form.renewal_auto_renew_default,
renewal_period_days: form.renewal_period_days,
retry_on_failure: form.renewal_retry_on_failure,
max_retry_count: form.renewal_max_retry_count,
grace_period_days: form.renewal_grace_period_days,
},
} }
if (isCreating.value) { if (isCreating.value) {

View File

@@ -21,7 +21,7 @@ api.interceptors.request.use(
} }
) )
// Response interceptor: handle 401 Unauthorized (expired/invalid token) // Response interceptor: handle 401 Unauthorized (expired/invalid token) and 403 Forbidden
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
@@ -30,6 +30,12 @@ api.interceptors.response.use(
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
// Optionally redirect to login could be added here later // Optionally redirect to login could be added here later
} }
// P0 BUGFIX (2026-07-28): Log but do NOT retry 403 errors.
// Permission errors should be terminal — they should not cause
// retry loops in Pinia stores or component watchers.
if (error.response?.status === 403) {
console.warn(`[API] 403 Forbidden: ${error.config?.url || 'unknown endpoint'}`)
}
return Promise.reject(error) return Promise.reject(error)
} }
) )

View File

@@ -679,7 +679,8 @@ const FUEL_SUBCATEGORY_CODES = new Set(['OPERATION_FUEL', 'OPERATION_ELECTRIC'])
*/ */
const selectedSubCategoryCode = computed<string | null>(() => { const selectedSubCategoryCode = computed<string | null>(() => {
if (!form.subCategory) return null if (!form.subCategory) return null
const allCats: CategoryOption[] = (window as any).__allCostCategories || [] const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
const found = allCats.find((c) => c.id === Number(form.subCategory)) const found = allCats.find((c) => c.id === Number(form.subCategory))
return found?.code || null return found?.code || null
}) })
@@ -791,13 +792,28 @@ const flattenCategories = (cats: any[]): any[] => {
}, []) }, [])
} }
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
* This guard prevents TypeError: allCategories.reduce/filter/find is not a function.
*/
function normalizeCategoryData(data: any): any[] {
if (Array.isArray(data)) return data
if (data && Array.isArray(data.items)) return data.items
if (data && Array.isArray(data.data)) return data.data
console.warn('[CostEntryWizard] Unexpected category format, using empty array:', data)
return []
}
async function fetchCategories() { async function fetchCategories() {
try { try {
// Pass visibility=b2c so only user-facing categories are returned // Pass visibility=b2c so only user-facing categories are returned
const res = await api.get('/dictionaries/cost-categories', { const res = await api.get('/dictionaries/cost-categories', {
params: { visibility: 'b2c' }, params: { visibility: 'b2c' },
}) })
const allCategories = flattenCategories(res.data) as CategoryOption[] const normalized = normalizeCategoryData(res.data)
const allCategories = flattenCategories(normalized) as CategoryOption[]
mainCategories.value = allCategories.filter((c) => c.parent_id === null) mainCategories.value = allCategories.filter((c) => c.parent_id === null)
window.__allCostCategories = allCategories window.__allCostCategories = allCategories
} catch (err) { } catch (err) {
@@ -821,7 +837,8 @@ function onMainCategoryChange() {
if (!form.mainCategory) return if (!form.mainCategory) return
const allCats: CategoryOption[] = (window as any).__allCostCategories || [] const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
subCategories.value = allCats.filter( subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory) (c) => c.parent_id === Number(form.mainCategory)
) )

View File

@@ -223,10 +223,24 @@ const vehicleLabel = computed(() => {
/** /**
* Fetch hierarchical categories from the dictionaries API. * Fetch hierarchical categories from the dictionaries API.
*/ */
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] } or null instead of a flat array.
* This defensive guard prevents TypeError: allCategories.filter is not a function.
*/
function normalizeCategories(data: any): CategoryOption[] {
if (Array.isArray(data)) return data as CategoryOption[]
if (data && Array.isArray(data.items)) return data.items as CategoryOption[]
if (data && Array.isArray(data.data)) return data.data as CategoryOption[]
console.warn('[ComplexExpenseModal] Unexpected category format, using empty array:', data)
return []
}
async function fetchCategories() { async function fetchCategories() {
try { try {
const res = await api.get('/dictionaries/cost-categories') const res = await api.get('/dictionaries/cost-categories')
const allCategories = res.data as CategoryOption[] const allCategories = normalizeCategories(res.data)
// Split into main (parent_id === null) and sub categories // Split into main (parent_id === null) and sub categories
mainCategories.value = allCategories.filter((c) => c.parent_id === null) mainCategories.value = allCategories.filter((c) => c.parent_id === null)
@@ -255,7 +269,8 @@ function onMainCategoryChange() {
if (!form.mainCategory) return if (!form.mainCategory) return
const allCats: CategoryOption[] = (window as any).__allCostCategories || [] const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
subCategories.value = allCats.filter( subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory) (c) => c.parent_id === Number(form.mainCategory)
) )
@@ -296,7 +311,8 @@ async function handleSubmit() {
if (props.isFeeMode) { if (props.isFeeMode) {
// Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID // Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
costType = 'fee' costType = 'fee'
const allCats: CategoryOption[] = (window as any).__allCostCategories || [] const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null) const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null)
categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails
} else { } else {

View File

@@ -33,9 +33,18 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex-1 flex flex-col gap-2.5 justify-center"> <div class="flex-1 flex flex-col gap-2.5 justify-center">
<!-- No vehicle warning --> <!-- Local loading skeleton -->
<div <div
v-if="!selectedActionVehicle" v-if="vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-[#70BC84] mb-2" />
<p class="text-xs">{{ t('dashboard.loading') }}</p>
</div>
<!-- No vehicle warning (only show when data has loaded but there are none) -->
<div
v-else-if="!selectedActionVehicle && !vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700" class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
> >
<span> {{ t('dashboard.noVehicleSelected') }}</span> <span> {{ t('dashboard.noVehicleSelected') }}</span>

View File

@@ -16,25 +16,36 @@
<!-- Content --> <!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0"> <div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
<div class="flex-1 overflow-y-auto space-y-2"> <div class="flex-1 overflow-y-auto space-y-2">
<VehicleCardCompact <!-- Local loading skeleton -->
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
:vehicle="vehicle"
class="relative z-20"
@click.stop="navigateToFleet"
@plate-click.stop="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div <div
v-if="displayVehicles.length === 0" v-if="vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20" class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
@click.stop="navigateToFleet"
> >
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-[#70BC84] mb-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" /> <p class="text-sm">{{ t('dashboard.loading') }}</p>
</svg>
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
</div> </div>
<template v-else>
<VehicleCardCompact
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
:vehicle="vehicle"
class="relative z-20"
@click.stop="navigateToFleet"
@plate-click.stop="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div
v-if="displayVehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
@click.stop="navigateToFleet"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -368,10 +368,24 @@ function autoCalculate() {
// ── Category Resolution ── // ── Category Resolution ──
const fuelCategoryId = ref<number>(1) // Default fallback const fuelCategoryId = ref<number>(1) // Default fallback
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
* This guard prevents TypeError: allCats.find is not a function.
*/
function normalizeCategories(data: any): Array<{ id: number; code?: string; name: string; parent_id: number | null }> {
if (Array.isArray(data)) return data
if (data && Array.isArray(data.items)) return data.items
if (data && Array.isArray(data.data)) return data.data
console.warn('[SimpleFuelModal] Unexpected category format, using empty array:', data)
return []
}
async function resolveFuelCategory() { async function resolveFuelCategory() {
try { try {
const res = await api.get('/dictionaries/cost-categories') const res = await api.get('/dictionaries/cost-categories')
const allCats = res.data as Array<{ id: number; code?: string; name: string; parent_id: number | null }> const allCats = normalizeCategories(res.data)
const fuelCat = allCats.find((c) => c.code === 'FUEL' && c.parent_id === null) const fuelCat = allCats.find((c) => c.code === 'FUEL' && c.parent_id === null)
if (fuelCat) { if (fuelCat) {
fuelCategoryId.value = fuelCat.id fuelCategoryId.value = fuelCat.id

View File

@@ -27,7 +27,7 @@
<svg class="w-4 h-4 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg> </svg>
{{ subscriptionPlan }} {{ subscriptionDisplayName }}
</span> </span>
<span <span
v-if="daysRemaining !== null" v-if="daysRemaining !== null"
@@ -112,6 +112,44 @@ const subscriptionPlan = computed(() => {
return authStore.user?.subscription_plan || null return authStore.user?.subscription_plan || null
}) })
/**
* P0 BUGFIX (2026-07-28): Resolve the human-readable subscription display name.
*
* Priority:
* 1. subscription_display_name from the backend (now populated by _resolve_subscription_data)
* 2. For corporate mode: try org-level subscription_display_name
* 3. Fallback: slug→human mapping for common plan names
* 4. Ultimate fallback: raw subscription_plan string
*/
const subscriptionDisplayName = computed(() => {
// Priority 1: user-level display_name from backend
const userDisplayName = authStore.user?.subscription_display_name
if (userDisplayName) return userDisplayName
// Priority 2: corporate mode — check org level
if (authStore.isCorporateMode) {
const activeOrg = authStore.myOrganizations.find(
(o) => o.organization_id === authStore.user?.active_organization_id
)
if (activeOrg?.subscription_display_name) return activeOrg.subscription_display_name
}
// Priority 3: slug→human mapping
const plan = subscriptionPlan.value
if (!plan || plan === 'FREE') return 'Ingyenes'
const slugMap: Record<string, string> = {
'private_free_v1': 'Privát Ingyenes',
'private_basic_v1': 'Privát Alap',
'private_pro_v1': 'Privát Pro',
'private_vip_v1': 'Privát VIP',
'corp_basic_v1': 'Céges Alap',
'corp_premium_v1': 'Céges Prémium',
'corp_premium_plus_v1': 'Céges Prémium Plus',
'corp_enterprise_v1': 'Céges Enterprise',
}
return slugMap[plan] || plan
})
/** /**
* Calculate days remaining until subscription expires. * Calculate days remaining until subscription expires.
* Uses valid_until from the organization or user data. * Uses valid_until from the organization or user data.
@@ -185,23 +223,20 @@ function formatDate(dateStr: string | null): string {
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
} }
onMounted(async () => { /**
// Ensure organizations are loaded * P0 BUGFIX (2026-07-28): Admin Loading Loop — removed redundant data fetches.
if (authStore.myOrganizations.length === 0) { *
try { * ROOT CAUSE: SubscriptionStatusWidget.onMounted() called vehicleStore.fetchVehicles()
await authStore.fetchMyOrganizations() * when vehicles.length === 0. This set vehicleStore.isLoading = true, which caused
} catch { * DashboardView to show the spinner and destroy the grid (v-if="!isLoading").
// Silently fail * When the fetch completed, isLoading = false, the grid re-mounted, SubscriptionStatusWidget
} * mounted fresh, checked vehicles.length === 0 again, re-fetched → INFINITE LOOP.
} *
// Ensure vehicles are loaded * FIX: The parent DashboardView already calls authStore.fetchMyOrganizations() and
if (vehicleStore.vehicles.length === 0) { * vehicleStore.fetchVehicles() in its own onMounted(). This widget should only
try { * set its local isLoading to false — it should NOT trigger its own data fetches.
await vehicleStore.fetchVehicles() */
} catch { onMounted(() => {
// Silently fail
}
}
isLoading.value = false isLoading.value = false
}) })
</script> </script>

View File

@@ -187,14 +187,17 @@ const activeGarageName = computed(() => {
return activeOrg.display_name || activeOrg.name || `${t('header.companyPrefix')} #${activeOrg.organization_id}` return activeOrg.display_name || activeOrg.name || `${t('header.companyPrefix')} #${activeOrg.organization_id}`
} }
} }
// If on dashboard and has an active individual org, show its name // If on dashboard and has an active organization, show its name
// ONLY if it's a business/corporate org (not individual/private)
if (!isOnCompanyGarage.value && authStore.user?.active_organization_id) { if (!isOnCompanyGarage.value && authStore.user?.active_organization_id) {
const activeOrg = authStore.myOrganizations.find( const activeOrg = authStore.myOrganizations.find(
(org) => org.organization_id === authStore.user?.active_organization_id (org) => org.organization_id === authStore.user?.active_organization_id
) )
if (activeOrg) { if (activeOrg && activeOrg.org_type !== 'individual') {
return activeOrg.display_name || activeOrg.name || t('header.garageSelector') return activeOrg.display_name || activeOrg.name || t('header.garageSelector')
} }
// Individual (private) orgs on dashboard: show "Saját Garázs" / "Private Garage"
return t('header.privateGarage')
} }
// Default: show personal garage label // Default: show personal garage label
return t('header.garageSelector') return t('header.garageSelector')

View File

@@ -662,11 +662,25 @@ function onFormFieldChange() {
}, 500) }, 500)
} }
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
* This guard prevents TypeError on .filter / .find / .reduce.
*/
function normalizeCategories(data: any): any[] {
if (Array.isArray(data)) return data
if (data && Array.isArray(data.items)) return data.items
if (data && Array.isArray(data.data)) return data.data
console.warn('[ProviderQuickAddModal] Unexpected category format, using empty array:', data)
return []
}
// ── Fetch Categories (legacy dropdown fallback) ── // ── Fetch Categories (legacy dropdown fallback) ──
async function fetchCategories() { async function fetchCategories() {
try { try {
const res = await api.get('providers/categories') const res = await api.get('providers/categories')
categories.value = res.data || [] categories.value = normalizeCategories(res.data)
} catch { } catch {
categories.value = [ categories.value = [
{ id: 1, key: 'auto_szerelo', name_hu: 'Autószerelő', name_en: 'Car Mechanic', category: 'vehicle_service' }, { id: 1, key: 'auto_szerelo', name_hu: 'Autószerelő', name_en: 'Car Mechanic', category: 'vehicle_service' },
@@ -949,19 +963,11 @@ watch(() => form.name, onFormFieldChange)
watch(() => form.city, onFormFieldChange) watch(() => form.city, onFormFieldChange)
watch(() => form.address_zip, onFormFieldChange) watch(() => form.address_zip, onFormFieldChange)
// ── Load categories on mount ── // P0 BUGFIX (2026-07-28): Removed onMounted pre-fetch and debug watchEffect.
onMounted(() => { // loadCategoryTree() and fetchCategories() are already called in the
fetchCategories() // watch(() => props.isOpen) handler (line 946-959), so pre-fetching on
loadCategoryTree() // mount is redundant AND harmful — it triggers API calls even when the
}) // modal is closed, contributing to the infinite admin reactivity loop.
// 🔍 P0 Debug: Universal Level 1 categories count verification
watchEffect(() => {
console.log(`[ProviderQuickAddModal] universalLevel1Categories count: ${universalLevel1Categories.value.length}`)
if (universalLevel1Categories.value.length > 0) {
console.log(`[ProviderQuickAddModal] Universal categories:`, universalLevel1Categories.value.map(c => c.name_hu || c.name_en))
}
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -142,10 +142,35 @@
</li> </li>
</ul> </ul>
</div> </div>
<!-- Phase 4: Selected Add-ons Summary (if any) -->
<div
v-if="cartAddonTotal > 0"
class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"
>
<p class="text-xs font-semibold text-emerald-700 uppercase tracking-wider mb-2">
🛒 {{ t('subscription.selectedAddons') }} ({{ selectedAddonCount }})
</p>
<div class="flex items-center justify-between text-sm">
<span class="text-emerald-800">{{ t('subscription.addonTotal') }}:</span>
<span class="font-bold text-emerald-700">{{ formatPrice(cartAddonTotal) }}/{{ t('subscription.month') }}</span>
</div>
</div>
</div> </div>
<!-- FOOTER --> <!-- FOOTER (with combined total if add-ons selected) -->
<div class="shrink-0 px-6 py-4 border-t border-slate-200 bg-slate-50"> <div class="shrink-0 px-6 py-4 border-t border-slate-200 bg-slate-50">
<!-- Combined total display -->
<div
v-if="cartAddonTotal > 0 && !isCurrentPlan"
class="flex items-center justify-between text-sm mb-3 px-2"
>
<span class="text-slate-600 font-medium">{{ t('subscription.monthlyTotal') }}:</span>
<span class="text-lg font-bold text-slate-900">
{{ formatPrice(combinedMonthlyPrice) }}/{{ t('subscription.month') }}
</span>
</div>
<button <button
class="w-full py-3 rounded-xl font-bold text-white transition-colors text-sm" class="w-full py-3 rounded-xl font-bold text-white transition-colors text-sm"
:class="isCurrentPlan ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-800 hover:bg-blue-900'" :class="isCurrentPlan ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-800 hover:bg-blue-900'"
@@ -162,6 +187,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/**
* Phase 4 CART LOGIC:
*
* This modal now accepts two new props:
* - cartAddonTotal: The sum of monthly prices of selected add-ons
* - selectedAddonCount: The number of selected add-ons
*
* The footer displays the combined monthly total
* (base plan monthly price + cartAddonTotal) when add-ons are selected.
*
* The combined price is only for display purposes — the actual purchase
* request sends the base plan's tier_id and includes addon_ids in metadata.
* The backend activates both the base plan and the selected add-ons.
*/
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -187,11 +226,17 @@ const props = withDefaults(
visible: boolean visible: boolean
plan: SubscriptionPlan | null plan: SubscriptionPlan | null
currentPlanName?: string | null currentPlanName?: string | null
/** Phase 4: Total monthly price of selected add-ons */
cartAddonTotal?: number
/** Phase 4: Number of selected add-ons */
selectedAddonCount?: number
}>(), }>(),
{ {
visible: false, visible: false,
plan: null, plan: null,
currentPlanName: null, currentPlanName: null,
cartAddonTotal: 0,
selectedAddonCount: 0,
} }
) )
@@ -216,6 +261,17 @@ const resolvedYearlyPrice = computed(() => {
return props.plan?.resolved_pricing?.yearly_price ?? props.plan?.rules?.pricing?.yearly_price ?? 0 return props.plan?.resolved_pricing?.yearly_price ?? props.plan?.rules?.pricing?.yearly_price ?? 0
}) })
/**
* Phase 4 CART LOGIC: Compute the combined monthly price (base plan + selected add-ons).
*
* This is the monthly price the user would pay if they purchase the base plan
* together with all selected add-ons. It is for display only — the actual
* billing is handled server-side.
*/
const combinedMonthlyPrice = computed(() => {
return resolvedMonthlyPrice.value + props.cartAddonTotal
})
/** /**
* Get resolved currency from plan's resolved_pricing or fallback to rules.pricing. * Get resolved currency from plan's resolved_pricing or fallback to rules.pricing.
*/ */

View File

@@ -172,9 +172,31 @@ const authStore = useAuthStore()
const vehicleStore = useVehicleStore() const vehicleStore = useVehicleStore()
// ── Computed: Plan display name ── // ── Computed: Plan display name ──
// P0 BUGFIX (2026-07-28): Backend now sends subscription_display_name via
// _resolve_subscription_data(). Use it with slug→human fallback matching
// SubscriptionStatusWidget logic.
const planDisplayName = computed(() => { const planDisplayName = computed(() => {
// Priority 1: Backend-supplied human-readable name from SubscriptionTier.rules
const displayName = authStore.user?.subscription_display_name
if (displayName) return displayName
// Priority 2: Slug-to-human mapping fallback
const plan = authStore.user?.subscription_plan const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free') if (!plan || plan === 'FREE') return t('subscription.free')
const slugToHuman: Record<string, string> = {
'private_basic_v1': 'Privát Alap',
'private_pro_v1': 'Privát Pro',
'private_vip_v1': 'Privát VIP',
'corp_free_v1': 'Céges Ingyenes',
'corp_basic_v1': 'Céges Alap',
'corp_pro_v1': 'Céges Pro',
'corp_vip_v1': 'Céges VIP',
'addon_extra_vehicle_v1': 'Extra Jármű',
}
if (slugToHuman[plan]) return slugToHuman[plan]
// Priority 3: Raw plan name as ultimate fallback
return plan return plan
}) })

View File

@@ -1,4 +1,8 @@
export default { export default {
// P2 BUGFIX (2026-07-28): Added missing i18n keys for ModeSwitcher component
switchMode: 'Switch Mode',
consumer: 'Consumer',
corporate: 'Corporate',
// ── Common / Global ── // ── Common / Global ──
common: { common: {
cancel: 'Cancel', cancel: 'Cancel',
@@ -71,6 +75,7 @@ export default {
apiAccess: 'API access', apiAccess: 'API access',
prioritySupport: 'Priority support', prioritySupport: 'Priority support',
adFree: 'Ad-free', adFree: 'Ad-free',
affiliateCommission: 'Affiliate commission',
}, },
// P0: Subscription Info Modal // P0: Subscription Info Modal
subscriptionInfo: 'Subscription Info', subscriptionInfo: 'Subscription Info',
@@ -85,6 +90,26 @@ export default {
unlimited: 'unlimited', unlimited: 'unlimited',
timeRemaining: 'Time Remaining', timeRemaining: 'Time Remaining',
days: 'days', days: 'days',
renewNow: 'Renew Now',
upgradePlan: 'Upgrade Plan',
// P0 Flip Card
registeredAt: 'Registration Date',
startedAt: 'Subscription Start',
activeAddons: 'Active Add-ons',
noAddons: 'No active add-ons',
validUntil: 'Valid until',
clickToFlipBack: 'Flip back',
details: 'Details',
// ── Phase 4: Add-on Cart & Back Button ──
backToFinance: '← Back to Finance',
addonsSection: 'Available Add-ons',
addonsDesc: 'Enhance your plan with extra features',
addToCart: 'Add to cart',
removeFromCart: 'Remove',
addonTotal: 'Add-on total',
monthlyTotal: 'Monthly total',
selectedAddons: 'Selected add-ons',
noAddonsAvailable: 'No add-ons available',
}, },
landing: { landing: {
openGarage: 'Open Garage', openGarage: 'Open Garage',

View File

@@ -1,4 +1,8 @@
export default { export default {
// P2 BUGFIX (2026-07-28): Added missing i18n keys for ModeSwitcher component
switchMode: 'Mód váltás',
consumer: 'Felhasználó',
corporate: 'Vállalat',
// ── Common / Globális ── // ── Common / Globális ──
common: { common: {
cancel: 'Mégse', cancel: 'Mégse',
@@ -71,6 +75,7 @@ export default {
apiAccess: 'API hozzáférés', apiAccess: 'API hozzáférés',
prioritySupport: 'Prioritásos támogatás', prioritySupport: 'Prioritásos támogatás',
adFree: 'Reklámmentes', adFree: 'Reklámmentes',
affiliateCommission: 'Partner jutalék',
}, },
// P0: Subscription Info Modal // P0: Subscription Info Modal
subscriptionInfo: 'Előfizetés Információ', subscriptionInfo: 'Előfizetés Információ',
@@ -85,6 +90,26 @@ export default {
unlimited: 'korlátlan', unlimited: 'korlátlan',
timeRemaining: 'Hátralévő idő', timeRemaining: 'Hátralévő idő',
days: 'nap', days: 'nap',
renewNow: 'Újítás most',
upgradePlan: 'Csomag váltása',
// P0 Flip Card
registeredAt: 'Regisztráció dátuma',
startedAt: 'Előfizetés kezdete',
activeAddons: 'Aktív kiegészítők',
noAddons: 'Nincsenek aktív kiegészítők',
validUntil: 'Érvényes',
clickToFlipBack: 'Visszafordítás',
details: 'Részletek',
// ── Phase 4: Add-on Cart & Back Button ──
backToFinance: '← Vissza a Pénzügyekhez',
addonsSection: 'Elérhető kiegészítők',
addonsDesc: 'Bővítsd a csomagod extra funkciókkal',
addToCart: 'Kosárba',
removeFromCart: 'Eltávolítás',
addonTotal: 'Kiegészítők összesen',
monthlyTotal: 'Havi összesen',
selectedAddons: 'Kiválasztott kiegészítők',
noAddonsAvailable: 'Nincsenek elérhető kiegészítők',
}, },
landing: { landing: {
openGarage: 'Garázs Nyitása', openGarage: 'Garázs Nyitása',

View File

@@ -64,6 +64,22 @@ export interface UserProfile {
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */ /** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number max_vehicles?: number
max_garages?: number max_garages?: number
/** 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
/** P0 Flip Card: User registration date (ISO 8601) */
user_registration_date?: string | null
/** P0 Flip Card: Current subscription start date (ISO 8601) */
subscription_valid_from?: string | null
/** P0 Flip Card: List of active add-on subscriptions */
active_addons?: Array<{
tier_id: number
display_name: string
valid_from: string | null
valid_until: string | null
is_active: boolean
}> | null
scope_level: string scope_level: string
scope_id: string | null scope_id: string | null
ui_mode: string ui_mode: string
@@ -88,6 +104,23 @@ export const useAuthStore = defineStore('auth', () => {
const myOrganizations = ref<OrganizationItem[]>([]) const myOrganizations = ref<OrganizationItem[]>([])
const isInitialized = ref(false) const isInitialized = ref(false)
/**
* Reactive version counter — incremented on every successful organization switch.
* Components and stores watch this to trigger data re-fetches for context-aware data.
*/
const contextVersion = ref(0)
// ── Deduplication guards ──────────────────────────────────────────
// P0 BUGFIX (2026-07-28): Prevent concurrent fetchUser() calls from
// App.vue.onMounted() and router.beforeEach. Without this guard,
// user.value gets set twice, triggering a reactivity cascade that
// causes the dashboard infinite mount/unmount loop for admin users.
let _userFetchInProgress = false
// P0 BUGFIX (2026-07-28): Single-execution guard for init() to prevent
// race conditions between App.vue.onMounted() and router.beforeEach.
let _initInProgress = false
// ────────────────────────────── Getters ──────────────────────────── // ────────────────────────────── Getters ────────────────────────────
const isAuthenticated = computed(() => !!token.value && !!user.value) const isAuthenticated = computed(() => !!token.value && !!user.value)
const isKycComplete = computed(() => { const isKycComplete = computed(() => {
@@ -248,7 +281,11 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('pending_verification_email') localStorage.removeItem('pending_verification_email')
// If KYC is not complete, redirect to KYC page // If KYC is not complete, redirect to KYC page
if (!isKycComplete.value) { // BUT skip for admin/staff users — they use the admin panel, not the B2C app
const staffRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']
const role = user.value?.role
const isStaffUser = role && staffRoles.includes(role.toUpperCase())
if (!isKycComplete.value && !isStaffUser) {
router.push('/complete-kyc') router.push('/complete-kyc')
} }
} catch (err: any) { } catch (err: any) {
@@ -263,8 +300,15 @@ export const useAuthStore = defineStore('auth', () => {
* Fetch the current user profile from /auth/me. * Fetch the current user profile from /auth/me.
*/ */
async function fetchUser() { async function fetchUser() {
// P0 BUGFIX (2026-07-28): Deduplication guard — prevents concurrent
// fetchUser() calls from App.vue.onMounted() and router.beforeEach.
// Without this, user.value is set twice, triggering a reactivity cascade
// that re-evaluates all computed properties in dashboard widgets.
if (_userFetchInProgress) return
if (!token.value) return if (!token.value) return
_userFetchInProgress = true
try { try {
const res = await api.get('/auth/me') const res = await api.get('/auth/me')
user.value = res.data as UserProfile user.value = res.data as UserProfile
@@ -274,6 +318,8 @@ export const useAuthStore = defineStore('auth', () => {
logout() logout()
} }
throw err throw err
} finally {
_userFetchInProgress = false
} }
} }
@@ -401,6 +447,13 @@ export const useAuthStore = defineStore('auth', () => {
* Initialize the store on app load — if a token exists, fetch the user. * Initialize the store on app load — if a token exists, fetch the user.
*/ */
async function init() { async function init() {
// P0 BUGFIX (2026-07-28): Single-execution guard. Prevents concurrent
// init() calls from App.vue.onMounted() and router.beforeEach().
// Without this, fetchUser() + fetchMyOrganizations() fire twice,
// causing reactivity cascade in dashboard widget computed properties.
if (_initInProgress || isInitialized.value) return
_initInProgress = true
if (token.value) { if (token.value) {
try { try {
await fetchUser() await fetchUser()
@@ -412,6 +465,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
// Mark initialization as complete so the router guard can await it // Mark initialization as complete so the router guard can await it
isInitialized.value = true isInitialized.value = true
_initInProgress = false
} }
/** /**
@@ -689,7 +743,18 @@ export const useAuthStore = defineStore('auth', () => {
* Fetch the current user's organizations from GET /api/v1/organizations/my. * Fetch the current user's organizations from GET /api/v1/organizations/my.
* Stores them in myOrganizations state. * Stores them in myOrganizations state.
*/ */
// P0 BUGFIX (2026-07-28): Deduplication guard to prevent multiple simultaneous
// calls from DashboardView.onMounted(), HeaderCompanySwitcher.onMounted(),
// and init(). Without this, rapid concurrent fetches cause race conditions
// in Pinia state that trigger excessive reactivity updates.
let _orgFetchInProgress = false
async function fetchMyOrganizations() { async function fetchMyOrganizations() {
if (_orgFetchInProgress) {
// Skip duplicate: a fetch is already in flight
return myOrganizations.value
}
_orgFetchInProgress = true
try { try {
const res = await api.get('/organizations/my') const res = await api.get('/organizations/my')
myOrganizations.value = res.data as OrganizationItem[] myOrganizations.value = res.data as OrganizationItem[]
@@ -698,6 +763,8 @@ export const useAuthStore = defineStore('auth', () => {
console.error('Failed to fetch organizations:', err) console.error('Failed to fetch organizations:', err)
myOrganizations.value = [] myOrganizations.value = []
return [] return []
} finally {
_orgFetchInProgress = false
} }
} }
@@ -723,7 +790,19 @@ export const useAuthStore = defineStore('auth', () => {
* (active_organization_id is set and matches a business org). * (active_organization_id is set and matches a business org).
*/ */
const isCorporateMode = computed(() => { const isCorporateMode = computed(() => {
return !!user.value?.active_organization_id if (!user.value?.active_organization_id) return false
// Find the active org and check its type to distinguish
// individual (private) orgs from business (corporate) orgs.
const activeOrg = myOrganizations.value.find(
(org) => org.organization_id === user.value?.active_organization_id
)
if (!activeOrg) {
// Fallback: if org data isn't loaded yet, assume corporate
// to avoid leaking corporate packages to private users
return true
}
// Individual orgs are NOT corporate mode
return activeOrg.org_type !== 'individual'
}) })
/** /**
@@ -752,6 +831,12 @@ export const useAuthStore = defineStore('auth', () => {
// Update the user state with the fresh profile // Update the user state with the fresh profile
user.value = data.user user.value = data.user
// P0 BUGFIX (2026-07-28): Context-switching data leak fix.
// Increment the version counter so DashboardView and other components
// watching authStore.contextVersion can trigger re-fetches of
// context-scoped data (vehicles, wallet balance, costs, etc.).
contextVersion.value++
} catch (err: any) { } catch (err: any) {
error.value = error.value =
err.response?.data?.detail || err.response?.data?.detail ||
@@ -771,6 +856,7 @@ export const useAuthStore = defineStore('auth', () => {
error, error,
myOrganizations, myOrganizations,
isInitialized, isInitialized,
contextVersion,
// Getters // Getters
isAuthenticated, isAuthenticated,

View File

@@ -138,7 +138,27 @@ export const useVehicleStore = defineStore('vehicle', () => {
* Fetch all vehicles/assets for the current user or their organization. * Fetch all vehicles/assets for the current user or their organization.
* GET /assets/vehicles * GET /assets/vehicles
*/ */
// P0 BUGFIX (2026-07-28): Deduplication guard to prevent multiple simultaneous
// calls from DashboardView.onMounted() and child components.
let _vehicleFetchInProgress = false
/**
* P0 BUGFIX (2026-07-28): Context-switching data leak fix.
* Clears the local vehicle cache and resets the deduplication guard.
* Called by DashboardView before re-fetching after organization switch.
*/
function clearCache() {
_vehicleFetchInProgress = false
vehicles.value = []
error.value = null
}
async function fetchVehicles() { async function fetchVehicles() {
if (_vehicleFetchInProgress) {
// Skip duplicate: a fetch is already in flight
return vehicles.value
}
_vehicleFetchInProgress = true
isLoading.value = true isLoading.value = true
error.value = null error.value = null
@@ -154,6 +174,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
console.error('[VehicleStore] fetchVehicles error:', err) console.error('[VehicleStore] fetchVehicles error:', err)
} finally { } finally {
isLoading.value = false isLoading.value = false
_vehicleFetchInProgress = false
} }
} }
@@ -433,6 +454,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
sortedVehicles, sortedVehicles,
// Actions // Actions
clearCache,
fetchVehicles, fetchVehicles,
fetchVehicleById, fetchVehicleById,
createVehicle, createVehicle,

View File

@@ -20,6 +20,8 @@ export interface OrganizationItem {
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */ /** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number max_vehicles?: number
max_garages?: number max_garages?: number
/** P0: Human-readable plan display name (e.g. "Privát Pro") */
subscription_display_name?: string | null
/** org_type is now reliably returned by GET /api/v1/organizations/my */ /** org_type is now reliably returned by GET /api/v1/organizations/my */
org_type: string org_type: string
/** /**

View File

@@ -38,17 +38,12 @@
<!-- Bottom-aligned 5-card container --> <!-- Bottom-aligned 5-card container -->
<div class="flex flex-col justify-end min-h-[85vh] pb-8"> <div class="flex flex-col justify-end min-h-[85vh] pb-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<!-- Loading State --> <!-- 5-Card Grid (P0: ALWAYS mounted, no global v-if guard)
CRITICAL: Do NOT add v-if="!vehicleStore.isLoading" here.
Each child widget has its own local loading state and handles
empty data gracefully. A global v-if would destroy the grid on
any loading toggle, causing infinite mount/unmount loops. -->
<div <div
v-if="vehicleStore.isLoading"
class="flex items-center justify-center py-20"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
</div>
<!-- 5-Card Grid -->
<div
v-if="!vehicleStore.isLoading"
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4" class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
style="perspective: 1200px;" style="perspective: 1200px;"
> >
@@ -362,9 +357,21 @@ function handleSetPrimaryFromDetail(vehicle: VehicleData) {
vehicleStore.setPrimaryVehicle(vehicle.id) vehicleStore.setPrimaryVehicle(vehicle.id)
} }
onMounted(() => { onMounted(async () => {
vehicleStore.fetchVehicles() // P0 BUGFIX (2026-07-28): Sequential fetches to prevent race conditions
authStore.fetchMyOrganizations() // between isLoading toggle and myOrganizations state updates that cause
// the infinite admin reactivity loop.
await vehicleStore.fetchVehicles()
// P0 BUGFIX (2026-07-28): Only fetch organizations if not already populated.
// authStore.init() already loads myOrganizations during app startup and
// router navigation guard. Unconditional re-fetching here creates a second
// API call that updates the ref, triggering a reactivity cascade in all
// dashboard widgets' computed properties (isCorporateMode, subscriptionPlan,
// validUntil, maxVehicles, etc.) — causing the admin infinite loop.
if (authStore.myOrganizations.length === 0) {
await authStore.fetchMyOrganizations()
}
// ── Handle query param card navigation ────────────────────────── // ── Handle query param card navigation ──────────────────────────
const cardParam = route.query.card as string | undefined const cardParam = route.query.card as string | undefined
@@ -382,6 +389,35 @@ watch(() => route.query.card, (newCard) => {
router.replace({ query: {} }) router.replace({ query: {} })
} }
}) })
// ── P0 BUGFIX (2026-07-28): Context-switching data leak fix ─────
// Watch authStore.contextVersion to trigger re-fetch of all context-scoped
// dashboard data when the user switches organizations (private garage ↔ company).
// Without this watcher, DashboardView.onMounted() only runs once, so
// vehicle + wallet data remains stale after a context switch.
watch(
() => authStore.contextVersion,
async () => {
// Skip initial mount (onMounted already fetches)
if (authStore.contextVersion === 0) return
console.log('[DashboardView] Context switch detected, re-fetching dashboard data...')
// Clear the vehicle cache and reset dedup guard so the fetch actually runs
vehicleStore.clearCache()
// Re-fetch vehicles for the new organization context
vehicleStore.fetchVehicles()
// Re-fetch wallet balance for the new organization context
const { useFinanceStore } = await import('../stores/finance')
const financeStore = useFinanceStore()
financeStore.fetchWalletBalance()
// Re-fetch organizations so the header switcher stays in sync
authStore.fetchMyOrganizations()
}
)
</script> </script>
<style scoped> <style scoped>

View File

@@ -116,38 +116,157 @@
</div> </div>
<!-- <!--
Card 2: 📦 Csomagok (Packages) Card 2: 📦 Csomagok (Packages) 3D FLIP CARD
Shows authStore.subscription_plan with active plan name. Front: display_name, expiry, vehicle bar, CTA button.
Back: registration date, valid_from, active add-ons list.
Click to flip CTA button uses @click.stop to not trigger flip.
--> -->
<div <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" class="flip-perspective bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="toggleFlip"
> >
<!-- Invisible overlay for click capture -->
<div <div
class="absolute inset-0 z-10 cursor-pointer" class="flip-inner"
@click="goTo('/finance/packages')" :class="{ 'is-flipped': isFlipped }"
/> >
<!-- Header bar --> <!-- FRONT FACE -->
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4"> <div class="flip-face flex flex-col">
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span> <!-- Header bar -->
</div> <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>
<!-- Content --> <span
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden"> :class="isExpired ? 'bg-red-500/30 text-red-200' : 'bg-emerald-500/30 text-emerald-200'"
<div class="flex-1 space-y-3"> class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
<!-- Current plan card --> >
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center"> {{ isExpired ? t('subscription.expired') : t('subscription.active') }}
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1"> </span>
{{ t('subscription.active') }}
</p>
<p class="text-2xl font-extrabold text-sky-600">
{{ subscriptionPlanName }}
</p>
</div> </div>
<!-- Quick info row --> <!-- Content -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center"> <div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<p class="text-xs text-slate-500">{{ t('subscription.subtitle') }}</p> <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 (click.stop prevents flip) -->
<div class="shrink-0 pt-2">
<router-link
:to="ctaRoute"
@click.stop
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>
<!-- BACK FACE -->
<div class="flip-face flip-back flex flex-col bg-white rounded-2xl">
<!-- Header bar (same gradient, mirrored) -->
<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.details') }}</span>
<button
@click.stop="toggleFlip"
class="ml-auto text-xs text-white/70 hover:text-white transition-colors"
>
{{ t('subscription.clickToFlipBack') }}
</button>
</div>
<!-- Back content: scrollable if overflow -->
<div class="flex-1 overflow-y-auto p-4 space-y-3 text-slate-800">
<!-- Registration date -->
<div v-if="userRegistrationDate" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500">{{ t('subscription.registeredAt') }}:</p>
<p class="text-sm font-semibold text-slate-700">{{ userRegistrationDate }}</p>
</div>
<!-- Subscription start date -->
<div v-if="subscriptionStartDate" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500">{{ t('subscription.startedAt') }}:</p>
<p class="text-sm font-semibold text-slate-700">{{ subscriptionStartDate }}</p>
</div>
<!-- Exact expiry date (prominent) -->
<div v-if="formattedExpiry" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500">{{ t('subscription.expiresAt') }}:</p>
<p
class="text-sm font-semibold"
:class="isExpired ? 'text-red-600' : 'text-slate-700'"
>{{ formattedExpiry }}</p>
</div>
<!-- Active Add-ons -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500 font-semibold mb-2">{{ t('subscription.activeAddons') }}:</p>
<div v-if="activeAddons.length === 0" class="text-xs text-slate-400 italic">
{{ t('subscription.noAddons') }}
</div>
<div v-else class="space-y-2">
<div
v-for="addon in activeAddons"
:key="addon.tier_id"
class="flex items-center justify-between text-xs"
>
<div class="flex flex-col">
<span class="font-medium text-slate-700">{{ addon.display_name }}</span>
<span class="text-slate-400">
{{ t('subscription.validUntil') }}: {{ formatAddonDate(addon.valid_until) }}
</span>
</div>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="isAddonExpired(addon)
? 'bg-red-100 text-red-600'
: 'bg-emerald-100 text-emerald-700'"
>
{{ isAddonExpired(addon) ? t('subscription.expired') : t('subscription.active') }}
</span>
</div>
</div>
</div>
<!-- Tip to flip back -->
<p class="text-center text-[10px] text-slate-400 pt-1">
{{ t('subscription.clickToFlipBack') }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -222,16 +341,18 @@
* Wallet balance fetched on mount via financeStore. * Wallet balance fetched on mount via financeStore.
* No flip mechanic — cards use direct @click routing. * No flip mechanic — cards use direct @click routing.
*/ */
import { computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useFinanceStore } from '../stores/finance' import { useFinanceStore } from '../stores/finance'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const financeStore = useFinanceStore() const financeStore = useFinanceStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
/** /**
* goTo — navigate to a finance sub-route using the router. * goTo — navigate to a finance sub-route using the router.
@@ -246,9 +367,62 @@ function goTo(path: string) {
const walletLoading = computed(() => financeStore.isLoading) const walletLoading = computed(() => financeStore.isLoading)
const walletError = computed(() => financeStore.error) const walletError = computed(() => financeStore.error)
// ── Card 2: Subscription plan name (authStore pattern) ──────────────── // ── Card 2: Subscription data (upgraded) ──────────────────────────
const subscriptionPlanName = computed(() => { const subscriptionDisplayName = computed(() => {
return authStore.user?.subscription_plan || '—' // 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
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}.${m}.${day}`
})
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')
}) })
// ── Helper: format credits ─────────────────────────────────────────── // ── Helper: format credits ───────────────────────────────────────────
@@ -259,8 +433,90 @@ function formatCredits(value: number): string {
return value.toFixed(2) return value.toFixed(2)
} }
// ── Fetch wallet balance on mount ──────────────────────────────────── // ── Flip card state ─────────────────────────────────────────
const isFlipped = ref(false)
function toggleFlip() {
isFlipped.value = !isFlipped.value
}
// ── Back face: user registration date ───────────────────────
const userRegistrationDate = computed(() => {
const raw = authStore.user?.user_registration_date
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
})
// ── Back face: subscription start date ──────────────────────
const subscriptionStartDate = computed(() => {
const raw = authStore.user?.subscription_valid_from
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
})
// ── Back face: active add-ons ───────────────────────────────
const activeAddons = computed(() => {
return authStore.user?.active_addons || []
})
// ── Back face: add-on helpers ───────────────────────────────
function formatAddonDate(raw: string | null): string {
if (!raw) return '—'
const d = new Date(raw)
if (isNaN(d.getTime())) return raw
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
}
function isAddonExpired(addon: { valid_until: string | null }): boolean {
if (!addon.valid_until) return false
return new Date(addon.valid_until) < new Date()
}
// ── Fetch data on mount ─────────────────────────────────────────────
onMounted(() => { onMounted(() => {
financeStore.fetchWalletBalance() financeStore.fetchWalletBalance()
// Ensure vehicles are loaded for usage display
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
}) })
</script> </script>
<style scoped>
/* ── 3D Flip Card ──────────────────────────────────────────── */
.flip-perspective {
perspective: 1000px;
}
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
transform-style: preserve-3d;
}
.flip-inner.is-flipped {
transform: rotateY(180deg);
}
.flip-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.flip-back {
transform: rotateY(180deg);
}
</style>

View File

@@ -6,10 +6,24 @@
</div> </div>
<div class="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Header --> <!-- HEADER: Title + Back Button -->
<div class="text-center mb-10"> <div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('subscription.title') }}</h1> <!-- Phase 4 UX FIX: Back Button -->
<p class="text-white/60">{{ t('subscription.subtitle') }}</p> <!-- Navigates to /finance/packages if coming from finance, otherwise /dashboard -->
<button
@click="goBack"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('subscription.backToFinance') }}
</button>
<div class="text-right">
<h1 class="text-3xl font-bold text-white">{{ t('subscription.title') }}</h1>
<p class="text-white/60 text-sm mt-1">{{ t('subscription.subtitle') }}</p>
</div>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
@@ -34,121 +48,257 @@
</button> </button>
</div> </div>
<!-- MINIMALIST PLAN CARDS --> <!-- PLANS + ADD-ONS -->
<div <div v-else class="space-y-10">
v-else <!-- BASE PLANS SECTION -->
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" <div
> v-if="basePlans.length > 0"
<BaseCard class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
v-for="plan in filteredPlans"
:key="plan.id"
:class="{
'ring-2 ring-emerald-500': plan.name === currentPlanName,
'hover:-translate-y-2 hover:shadow-2xl': true,
}"
header-color="bg-[#306081]"
height="auto"
body-padding="lg"
:custom-class="`border-2 ${plan.name === currentPlanName ? 'border-emerald-500' : 'border-slate-800'} transition-all duration-300`"
> >
<!-- HEADER: Plan Name + Badge --> <BaseCard
<template #header> v-for="plan in basePlans"
<div class="flex items-center justify-between w-full"> :key="plan.id"
<span class="text-white font-bold text-sm tracking-wide"> :class="{
{{ plan.rules?.display_name || plan.name }} 'ring-2 ring-emerald-500': plan.name === currentPlanName,
</span> 'hover:-translate-y-2 hover:shadow-2xl': true,
<span }"
v-if="plan.rules?.marketing?.badge" header-color="bg-[#306081]"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full" height="auto"
:style="{ body-padding="lg"
backgroundColor: plan.rules.marketing.highlight_color || '#70BC84', :custom-class="`border-2 ${plan.name === currentPlanName ? 'border-emerald-500' : 'border-slate-800'} transition-all duration-300`"
color: '#fff', >
}" <!-- HEADER: Plan Name + Badge -->
> <template #header>
{{ plan.rules.marketing.badge }} <div class="flex items-center justify-between w-full">
</span> <span class="text-white font-bold text-sm tracking-wide">
</div> {{ plan.rules?.display_name || plan.name }}
</template> </span>
<span
<!-- BODY: Minimalist Content --> v-if="plan.rules?.marketing?.badge"
<div class="flex flex-col h-full"> class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full"
<!-- Subtitle --> :style="{
<p backgroundColor: plan.rules.marketing.highlight_color || '#70BC84',
v-if="plan.rules?.marketing?.subtitle" color: '#fff',
class="text-xs text-slate-500 mb-4 leading-relaxed" }"
> >
{{ plan.rules.marketing.subtitle }} {{ plan.rules.marketing.badge }}
</p>
<!-- Price (from resolved_pricing) -->
<div class="text-center mb-5">
<div class="text-3xl font-bold text-slate-900">
{{ formatPrice(resolvedMonthlyPrice(plan)) }}
<span class="text-sm font-normal text-slate-500">/{{ t('subscription.month') }}</span>
</div>
<!-- Yearly price hint -->
<div v-if="resolvedYearlyPrice(plan) > 0" class="text-xs text-slate-400 mt-1">
{{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }}
<span v-if="yearlySavingsPercent(plan) > 0" class="text-emerald-600 font-semibold ml-1">
({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }})
</span> </span>
</div> </div>
</div> </template>
<!-- 3 KEY STATS (visual highlight) --> <!-- BODY: Minimalist Content -->
<div class="grid grid-cols-3 gap-2 mb-5"> <div class="flex flex-col h-full">
<div class="bg-slate-50 rounded-lg p-2.5 text-center"> <!-- Subtitle -->
<div class="text-base font-bold text-slate-900"> <p
{{ plan.rules?.allowances?.max_vehicles ?? '—' }} v-if="plan.rules?.marketing?.subtitle"
</div> class="text-xs text-slate-500 mb-4 leading-relaxed"
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider"> >
{{ t('subscription.maxVehicles') }} {{ plan.rules.marketing.subtitle }}
</div> </p>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_garages ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxGarages') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.monthly_free_credits ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.freeCredits') }}
</div>
</div>
</div>
<!-- Spacer --> <!-- Price (from resolved_pricing) -->
<div class="flex-1" /> <div class="text-center mb-5">
<div class="text-3xl font-bold text-slate-900">
<!-- Action: Current Plan Badge or Details Button --> {{ formatPrice(resolvedMonthlyPrice(plan)) }}
<div v-if="plan.name === currentPlanName" class="mt-auto"> <span class="text-sm font-normal text-slate-500">/{{ t('subscription.month') }}</span>
<div class="text-center text-sm text-emerald-600 font-semibold mb-3 bg-emerald-50 py-2 rounded-lg border border-emerald-200"> </div>
{{ t('subscription.currentPlan') }} <!-- Yearly price hint -->
<div v-if="resolvedYearlyPrice(plan) > 0" class="text-xs text-slate-400 mt-1">
{{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }}
<span v-if="yearlySavingsPercent(plan) > 0" class="text-emerald-600 font-semibold ml-1">
({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }})
</span>
</div>
</div> </div>
<!-- 3 KEY STATS (visual highlight) -->
<div class="grid grid-cols-3 gap-2 mb-5">
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_vehicles ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxVehicles') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_garages ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxGarages') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.monthly_free_credits ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.freeCredits') }}
</div>
</div>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Action: Current Plan Badge or Details Button -->
<div v-if="plan.name === currentPlanName" class="mt-auto">
<div class="text-center text-sm text-emerald-600 font-semibold mb-3 bg-emerald-50 py-2 rounded-lg border border-emerald-200">
{{ t('subscription.currentPlan') }}
</div>
</div>
<button
v-else
@click="openDetails(plan)"
class="w-full py-2.5 rounded-xl bg-blue-800 hover:bg-blue-900 text-white font-semibold transition-colors text-sm mt-auto"
>
{{ t('subscription.detailsAndPurchase') }}
</button>
</div> </div>
</BaseCard>
</div>
<!-- EMPTY BASE PLANS -->
<div v-else class="text-center py-10">
<p class="text-white/50">{{ t('subscription.noPlan') }}</p>
</div>
<!--
Phase 4: ADD-ON SECTION (Issue #432)
PURPOSE: Display available add-on packages beneath base plans.
Each add-on card shows its monthly price and a toggle button.
The cart state tracks which add-ons the user has selected.
CART LOGIC:
- selectedAddonIds: Set<number> tracks selected add-on IDs
- toggleAddon(id): adds/removes the add-on from the cart
- cartAddonTotal: sum of monthly prices of selected add-ons
- When the user opens a PlanDetailsModal for a base plan,
the modal footer shows the combined total
(base plan price + selected add-on prices).
-->
<div v-if="addonPlans.length > 0" class="space-y-4">
<!-- Section Header -->
<div class="text-center">
<h2 class="text-2xl font-bold text-white">
🔧 {{ t('subscription.addonsSection') }}
</h2>
<p class="text-white/60 text-sm mt-1">{{ t('subscription.addonsDesc') }}</p>
</div>
<!-- Cart summary bar (visible when add-ons are selected) -->
<div
v-if="cartAddonTotal > 0"
class="rounded-xl bg-white/10 backdrop-blur-sm border border-white/20 p-3 flex items-center justify-between text-white"
>
<span class="text-sm">
🛒 {{ t('subscription.selectedAddons') }} ({{ selectedAddonIds.size }}):
<strong>{{ formatPrice(cartAddonTotal) }}/{{ t('subscription.month') }}</strong>
</span>
<button <button
v-else @click="clearCart"
@click="openDetails(plan)" class="text-xs text-white/60 hover:text-white transition-colors underline"
class="w-full py-2.5 rounded-xl bg-blue-800 hover:bg-blue-900 text-white font-semibold transition-colors text-sm mt-auto"
> >
{{ t('subscription.detailsAndPurchase') }} {{ t('common.delete') }}
</button> </button>
</div> </div>
</BaseCard>
<!-- Add-on cards grid solid white cards with green gradient header -->
<!-- Phase 4 BUGFIX (2026-07-28): Replaced dark glass classes (bg-emerald-900/20,
border-slate-700, text-white/xx) with solid white card pattern matching
the base plan cards. Added green emerald gradient header for visual
distinction. See docs/UI_STANDARDS.md Section 6. -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="addon in addonPlans"
:key="addon.id"
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col transition-all duration-300"
:class="isAddonSelected(addon.id)
? 'border-emerald-500 ring-1 ring-emerald-500/50 hover:-translate-y-2 hover:shadow-2xl'
: 'border-slate-200 hover:border-slate-400 hover:-translate-y-2 hover:shadow-2xl'"
>
<!-- Green gradient header bar (distinguishes add-ons from base plan dark blue headers) -->
<div class="h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">
{{ addon.rules?.display_name || addon.name }}
</span>
<span class="ml-auto text-white/80 text-xs font-bold">
{{ formatPrice(resolvedMonthlyPrice(addon)) }}/{{ t('subscription.month') }}
</span>
</div>
<!-- Body -->
<div class="p-4 flex-1 flex flex-col text-slate-800">
<!-- Subtitle -->
<p v-if="addon.rules?.marketing?.subtitle" class="text-xs text-slate-500 mb-3 leading-relaxed">
{{ addon.rules.marketing.subtitle }}
</p>
<!-- Allowances mini stat boxes (matching base plan stat style) -->
<div class="grid grid-cols-3 gap-2 mb-3">
<div v-if="addon.rules?.allowances?.max_vehicles" class="bg-slate-50 rounded-lg p-2 text-center">
<div class="text-base font-bold text-slate-900">
+{{ addon.rules.allowances.max_vehicles }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
🚗 {{ t('subscription.vehicles') }}
</div>
</div>
<div v-if="addon.rules?.allowances?.max_garages" class="bg-slate-50 rounded-lg p-2 text-center">
<div class="text-base font-bold text-slate-900">
+{{ addon.rules.allowances.max_garages }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
🏠 {{ t('subscription.maxGarages') }}
</div>
</div>
<div v-if="addon.rules?.allowances?.monthly_free_credits" class="bg-slate-50 rounded-lg p-2 text-center">
<div class="text-base font-bold text-slate-900">
+{{ addon.rules.allowances.monthly_free_credits }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
💰 {{ t('subscription.freeCredits') }}
</div>
</div>
<!-- Empty placeholder when some stat boxes are missing (keeps grid aligned) -->
<div v-if="!addon.rules?.allowances?.max_vehicles && !addon.rules?.allowances?.max_garages && !addon.rules?.allowances?.monthly_free_credits" class="col-span-3 text-xs text-slate-400 italic text-center py-2">
{{ t('subscription.noAddonsAvailable') }}
</div>
</div>
<div class="flex-1" />
<!-- Toggle button solid bg matching standard card buttons -->
<button
@click="toggleAddon(addon.id)"
class="w-full py-2 rounded-xl text-sm font-semibold transition-all duration-200"
:class="isAddonSelected(addon.id)
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
: 'bg-slate-100 hover:bg-emerald-50 text-slate-700 hover:text-emerald-700 border border-slate-200 hover:border-emerald-300'"
>
<template v-if="isAddonSelected(addon.id)">
{{ t('subscription.removeFromCart') }}
</template>
<template v-else>
+ {{ t('subscription.addToCart') }}
</template>
</button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- PLAN DETAILS MODAL --> <!-- PLAN DETAILS MODAL (with add-on cart total in footer) -->
<PlanDetailsModal <PlanDetailsModal
:visible="showModal" :visible="showModal"
:plan="selectedPlan" :plan="selectedPlan"
:current-plan-name="currentPlanName" :current-plan-name="currentPlanName"
:cart-addon-total="cartAddonTotal"
:selected-addon-count="selectedAddonIds.size"
@close="closeModal" @close="closeModal"
@order="handleOrder" @order="handleOrder"
/> />
@@ -157,12 +307,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import api from '../api/axios' import api from '../api/axios'
import BaseCard from '../components/ui/BaseCard.vue' import BaseCard from '../components/ui/BaseCard.vue'
import PlanDetailsModal from '../components/subscription/PlanDetailsModal.vue' import PlanDetailsModal from '../components/subscription/PlanDetailsModal.vue'
const router = useRouter()
const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -185,22 +338,140 @@ const isLoading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const allPlans = ref<SubscriptionPlan[]>([]) const allPlans = ref<SubscriptionPlan[]>([])
// ── Phase 4: Add-on Cart State ─────────────────────────────────────────────────
/**
* Cart state for add-on selection.
* Uses a Set<number> to track selected add-on IDs (O(1) lookup).
*
* CART LOGIC:
* - toggleAddon(id): Adds or removes the add-on from the cart.
* - clearCart(): Empties the cart.
* - cartAddonTotal: Computed sum of monthly prices of all selected add-ons.
* This is passed to PlanDetailsModal so the modal footer can show
* "Base plan price + Add-on total = Combined monthly total".
*/
const selectedAddonIds = ref<Set<number>>(new Set())
/**
* Toggle an add-on's selection state.
* If already selected, remove it; otherwise add it.
*/
function toggleAddon(addonId: number) {
const newSet = new Set(selectedAddonIds.value)
if (newSet.has(addonId)) {
newSet.delete(addonId)
} else {
newSet.add(addonId)
}
selectedAddonIds.value = newSet
}
/**
* Check if a given add-on ID is currently in the cart.
*/
function isAddonSelected(addonId: number): boolean {
return selectedAddonIds.value.has(addonId)
}
/**
* Clear all selected add-ons from the cart.
*/
function clearCart() {
selectedAddonIds.value = new Set()
}
/**
* Phase 4 CART LOGIC: Compute the total monthly price of selected add-ons.
*
* Iterates over addonPlans and sums the monthly prices of those whose
* IDs are in selectedAddonIds. Used by the modal footer to display
* the combined monthly total.
*/
const cartAddonTotal = computed(() => {
let total = 0
for (const addon of addonPlans.value) {
if (selectedAddonIds.value.has(addon.id)) {
total += resolvedMonthlyPrice(addon)
}
}
return total
})
// ── Phase 4: Separate base plans from add-ons ─────────────────────────────────
/**
* Base plans: subscription tiers where rules.type is NOT 'addon'.
* These are the main subscription packages (private_*, corp_*, etc.).
*/
const basePlans = computed(() => {
return filteredPlans.value.filter((plan) => {
const planType = plan.rules?.type
return planType !== 'addon'
})
})
/**
* Add-on plans: subscription tiers where rules.type === 'addon'.
* These are supplementary packages (extra vehicles, extra garages, etc.).
*/
const addonPlans = computed(() => {
return filteredPlans.value.filter((plan) => {
const planType = plan.rules?.type
return planType === 'addon'
})
})
// Modal state // Modal state
const showModal = ref(false) const showModal = ref(false)
const selectedPlan = ref<SubscriptionPlan | null>(null) const selectedPlan = ref<SubscriptionPlan | null>(null)
/**
* Phase 4 UX FIX: Navigate back to the appropriate previous view.
*
* Navigation logic:
* - If the referrer contains 'finance', go to /finance/packages
* - If the route has an org_id param, go to /organization/:org_id
* - Otherwise go to /dashboard/subscription
*/
function goBack() {
// Check if we're in an organization context
const orgId = route.params.org_id
if (orgId) {
router.push(`/organization/${orgId}`)
return
}
// Check referrer
const referrer = document.referrer || ''
if (referrer.includes('/finance')) {
router.push('/finance/packages')
return
}
router.push('/dashboard')
}
/** /**
* Filter plans based on the user's current mode: * Filter plans based on the user's current mode:
* - Individual (no active org): show only `private_` packages * - Individual (no active org): show only `private_` packages
* - Corporate (active org): show only `corp_` packages * - Corporate (active org): show only `corp_` packages
* Add-ons (type === 'addon') are NOT filtered by mode — they show for everyone.
*/ */
const filteredPlans = computed(() => { const filteredPlans = computed(() => {
const isCorporate = authStore.isCorporateMode const isCorporate = authStore.isCorporateMode
return allPlans.value.filter((plan) => { return allPlans.value.filter((plan) => {
const planType = plan.rules?.type
// Add-ons are always visible regardless of mode
if (planType === 'addon') return true
// For base plans, apply mode-based filtering
if (planType === 'corporate' || planType === 'business') return isCorporate
if (planType === 'private' || planType === 'consumer') return !isCorporate
// Fallback: name prefix matching
if (isCorporate) { if (isCorporate) {
return plan.name.startsWith('corp_') return plan.name.startsWith('corp_') || plan.name.startsWith('business_')
} }
return plan.name.startsWith('private_') return plan.name.startsWith('private_') || plan.name.startsWith('consumer_')
}) })
}) })
@@ -285,16 +556,58 @@ function closeModal() {
} }
/** /**
* Handle order from modal (simulation). * Phase 4 CART LOGIC: Handle order with add-on IDs in metadata.
*
* When the user purchases a base plan, the selected add-on IDs are
* passed in the `metadata.addon_ids` field so the backend can also
* activate the corresponding add-on subscriptions.
*/ */
function handleOrder(plan: SubscriptionPlan, isYearly: boolean) { async function handleOrder(plan: SubscriptionPlan, isYearly: boolean) {
console.log('[SubscriptionPlansView] Order simulation:', { const price = isYearly ? resolvedYearlyPrice(plan) : resolvedMonthlyPrice(plan)
plan: plan.name, const currency = resolvedCurrency(plan)
isYearly,
price: isYearly ? resolvedYearlyPrice(plan) : resolvedMonthlyPrice(plan), // Determine if this should be an org-level subscription
currency: resolvedCurrency(plan), const orgId = authStore.isCorporateMode && authStore.user?.active_organization_id
}) ? authStore.user.active_organization_id
// TODO: Integrate with actual checkout flow : null
try {
// Phase 4: Include selected add-on IDs in the metadata
const metadata: Record<string, any> = {}
if (selectedAddonIds.value.size > 0) {
metadata.addon_ids = Array.from(selectedAddonIds.value)
}
const res = await api.post('/financial-manager/purchase-package', {
tier_id: plan.id,
org_id: orgId,
region_code: authStore.user?.region_code || 'GLOBAL',
currency: currency,
metadata: metadata,
})
const data = res.data
if (data.success) {
console.log('[SubscriptionPlansView] Purchase successful:', data)
// Refresh user profile to get updated subscription state
await authStore.fetchUser()
if (authStore.user?.active_organization_id) {
await authStore.fetchMyOrganizations()
}
// Clear cart after successful purchase
clearCart()
// Show success message
alert(t('subscription.purchaseSuccess') || 'Subscription activated successfully!')
} else {
console.error('[SubscriptionPlansView] Purchase failed:', data.error)
alert(data.error || t('subscription.purchaseError') || 'Purchase failed.')
}
} catch (err: any) {
console.error('[SubscriptionPlansView] Purchase error:', err)
const detail = err.response?.data?.detail || err.message || 'Purchase failed'
alert(detail)
}
closeModal() closeModal()
} }

View File

@@ -0,0 +1,475 @@
# 🏗️ Code Mode Blueprint: Finance Flipping Card + Context-Aware Package Filter
**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
---
## 📋 Quick Summary
This blueprint covers TWO distinct features for the Finance module:
| # | Feature | Complexity | Files Changed |
|---|---------|------------|---------------|
| 1 | **CSS Flipping Card** — Card 2 in `FinanceMainView.vue` becomes a 3D flip card showing subscription details on front, registration date + valid_from + active add-ons on back | Medium | 5 backend, 5 frontend |
| 2 | **Context-Aware Package Filter**`SubscriptionPlansView.vue` already filters by name prefix; this blueprint enhances it to use `rules.type` JSONB field | Low | 1 frontend |
---
## 🔍 FEATURE 1: CSS Flipping Card (Card 2 Upgrade)
### 1.1 Current State (What Exists Today)
`FinanceMainView.vue` (frontend_app/src/views/FinanceMainView.vue:118-188) Card 2 has already been upgraded (per the earlier subscription card upgrade spec) with:
- `subscriptionDisplayName` — human-readable plan name from backend
- `formattedExpiry` — formatted expiry date
- Vehicle usage progress bar
- Dynamic CTA button
**Current card structure:**
```html
<div class="bg-white/95 rounded-2xl shadow-[...] 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 (sky gradient) -->
<!-- Content: plan name, expiry, vehicle bar, CTA -->
</div>
```
This is a **single-layer static card** — no flip mechanic, no back face.
### 1.2 What the Flip Card Must Show
**Front side (already exists):**
- Status badge (Active/Lejárt)
- Human-readable plan name (e.g., "Privát Pro")
- Expiry date (YYYY.MM.DD format)
- Vehicle usage bar (vehicleCount / maxVehicles)
- "Csomag kezelése" CTA button
**Back side (NEW — revealed on click):**
- 📅 User registration date (`user.created_at`)
- 🗓️ Current subscription start date (`OrganizationSubscription.valid_from` or `UserSubscription.valid_from`)
- 📆 Exact expiry date (same as front, but more prominent)
- List of active Add-ons (kiegészítők) with each add-on's:
- Display name (from `SubscriptionTier.rules.display_name` of type='addon')
- Expiry date (individual `valid_until`)
- Status badge (Active/Expired per add-on)
### 1.3 Data Availability Gap Analysis
#### ✅ Already Available via `/auth/me`
| Data Field | Source | Status |
|------------|--------|--------|
| `subscription_display_name` | `SubscriptionTier.rules["display_name"]` | ✅ Already resolved at users.py:291-293 |
| `subscription_expires_at` | `UserSubscription.valid_until` / `OrganizationSubscription.valid_until` | ✅ Already resolved at users.py:302-304 |
| `subscription_tier_id` | FK from subscription record | ✅ Already resolved at users.py:273-274,288-289 |
| `max_vehicles`, `max_garages` | `SubscriptionTier.rules.allowances` | ✅ Already resolved at users.py:225-253 |
#### ❌ NOT Yet Available via `/auth/me` — MUST ADD
| Data Field | Source | Where to Add |
|------------|--------|-------------|
| `user_registration_date` | `User.created_at` (backend/app/models/identity/identity.py:212) | `_build_user_response()` or `read_users_me()` |
| `subscription_valid_from` | `OrganizationSubscription.valid_from` / `UserSubscription.valid_from` | `read_users_me()` in the P0 subscription resolution block |
| `active_addons` (list) | Multiple subscription rows where `tier.type='addon'` | `read_users_me()` — NEW query needed |
### 1.4 Add-on Storage Architecture
The P0 MULTI-SUBSCRIPTION AGGREGATION model uses the pattern **"1 Base + N Add-on"**:
```
finance.user_subscriptions (Personal mode)
├─ tier.type = 'base' (or NULL) ← core subscription (e.g., "Privát Pro")
├─ tier.type = 'addon' ← add-on 1 (e.g., "+5 extra vehicles")
└─ tier.type = 'addon' ← add-on 2 (e.g., "+Analytics module")
finance.org_subscriptions (Corporate mode)
├─ tier.type = 'base' (or NULL) ← core subscription (e.g., "Céges Prémium")
├─ tier.type = 'addon' ← add-on 1
└─ tier.type = 'addon' ← add-on 2
```
The `SubscriptionTier.type` column (backend/app/models/core_logic.py:89-95) distinguishes:
- `'base'` or `NULL` → core tier
- `'addon'` → add-on tier
Each add-on has its OWN subscription record with its own `valid_from`, `valid_until`, `tier_id`, and `is_active` flag.
### 1.5 Backend Changes Required
#### File: backend/app/api/v1/endpoints/users.py
**Step 1: Add `created_at` to `_build_user_response()` return dict (after line 131)**
```python
"created_at": user.created_at.isoformat() if user.created_at else None,
```
**Step 2: Initialize `subscription_valid_from` variable (at line 256)**
```python
subscription_valid_from: Optional[datetime] = None
```
**Step 3: Extract `valid_from` in org branch (after line 273)**
```python
subscription_valid_from = org_sub_full.valid_from
```
**Step 4: Extract `valid_from` in personal branch (after line 288)**
```python
subscription_valid_from = user_sub_full.valid_from
```
**Step 5: Resolve active add-ons (NEW block after line 289)**
```python
# ── P0 Flip Card: Resolve active add-on subscriptions ──
active_addons: List[Dict[str, Any]] = []
if active_org_id is not None:
addon_stmt = (
select(OrganizationSubscription, SubscriptionTier)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
SubscriptionTier.type == 'addon',
)
.order_by(OrganizationSubscription.valid_from.desc())
)
addon_result = await db.execute(addon_stmt)
for sub, tier in addon_result:
active_addons.append({
"tier_id": tier.id,
"display_name": tier.rules.get("display_name", tier.name) if tier.rules else tier.name,
"valid_from": sub.valid_from.isoformat() if sub.valid_from else None,
"valid_until": sub.valid_until.isoformat() if sub.valid_until else None,
"is_active": sub.is_active,
})
else:
addon_stmt = (
select(UserSubscription, SubscriptionTier)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == current_user.id,
UserSubscription.is_active == True,
SubscriptionTier.type == 'addon',
)
.order_by(UserSubscription.valid_from.desc())
)
addon_result = await db.execute(addon_stmt)
for sub, tier in addon_result:
active_addons.append({
"tier_id": tier.id,
"display_name": tier.rules.get("display_name", tier.name) if tier.rules else tier.name,
"valid_from": sub.valid_from.isoformat() if sub.valid_from else None,
"valid_until": sub.valid_until.isoformat() if sub.valid_until else None,
"is_active": sub.is_active,
})
```
**Step 6: Add new fields to response dict (after line 305)**
```python
response_data["subscription_valid_from"] = (
subscription_valid_from.isoformat() if subscription_valid_from else None
)
response_data["active_addons"] = active_addons
```
**Summary of backend changes:**
| Change | Location | ~Lines |
|--------|----------|--------|
| Add `created_at` to `_build_user_response()` | After line 131 | +1 |
| Init `subscription_valid_from` variable | Line 256 | +1 |
| Extract `valid_from` in org branch | After line 273 | +1 |
| Extract `valid_from` in personal branch | After line 288 | +1 |
| Add-on resolution query block | After line 289 | +35 |
| Response dict additions | After line 305 | +4 |
### 1.6 Frontend Changes Required
#### File: frontend_app/src/stores/auth.ts
**Add new fields to `UserProfile` interface (after line 70):**
```typescript
/** P0 Flip Card: User registration date (ISO 8601) */
user_registration_date?: string | null
/** P0 Flip Card: Current subscription start date (ISO 8601) */
subscription_valid_from?: string | null
/** P0 Flip Card: List of active add-on subscriptions */
active_addons?: Array<{
tier_id: number
display_name: string
valid_from: string | null
valid_until: string | null
is_active: boolean
}> | null
```
#### File: frontend_app/src/views/FinanceMainView.vue
**Step 1: Import `ref` from Vue (line 259):**
Change:
```typescript
import { computed, onMounted } from 'vue'
```
To:
```typescript
import { computed, onMounted, ref } from 'vue'
```
**Step 2: Add flip state and back-face computed properties (after `ctaLabel` computed at line 338):**
```typescript
// ── Flip card state ─────────────────────────────────────────
const isFlipped = ref(false)
function toggleFlip() {
isFlipped.value = !isFlipped.value
}
// ── Back face: user registration date ───────────────────────
const userRegistrationDate = computed(() => {
const raw = authStore.user?.user_registration_date
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
})
// ── Back face: subscription start date ──────────────────────
const subscriptionStartDate = computed(() => {
const raw = authStore.user?.subscription_valid_from
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
})
// ── Back face: active add-ons ───────────────────────────────
const activeAddons = computed(() => {
return authStore.user?.active_addons || []
})
```
**Step 3: Add helper functions (after `formatCredits` at line 349):**
```typescript
function formatAddonDate(raw: string): string {
const d = new Date(raw)
if (isNaN(d.getTime())) return raw
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
}
function isAddonExpired(addon: { valid_until: string | null }): boolean {
if (!addon.valid_until) return false
return new Date(addon.valid_until) < new Date()
}
```
**Step 4: Replace Card 2 template (lines 118-188) with the flip card structure:**
Replace the entire Card 2 block (from `<!-- ═══ Card 2: 📦 Csomagok ... -->` comment to its closing `</div>`) with the flip card template provided below in the full template section.
**Step 5: Add flip CSS to a `<style scoped>` block at the end of the file:**
```css
/* ── 3D Flip Card ──────────────────────────────────────────── */
.flip-perspective {
perspective: 1000px;
}
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
transform-style: preserve-3d;
}
.flip-inner.is-flipped {
transform: rotateY(180deg);
}
.flip-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.flip-back {
transform: rotateY(180deg);
}
```
#### File: frontend_app/src/i18n/hu.ts — Add new keys inside `subscription:` block
```typescript
registeredAt: 'Regisztráció dátuma',
startedAt: 'Előfizetés kezdete',
activeAddons: 'Aktív kiegészítők',
noAddons: 'Nincsenek aktív kiegészítők',
validUntil: 'Érvényes',
clickToFlipBack: 'Visszafordítás',
details: 'Részletek',
```
#### File: frontend_app/src/i18n/en.ts — Add new keys inside `subscription:` block
```typescript
registeredAt: 'Registration Date',
startedAt: 'Subscription Start',
activeAddons: 'Active Add-ons',
noAddons: 'No active add-ons',
validUntil: 'Valid until',
clickToFlipBack: 'Flip back',
details: 'Details',
```
---
## 🔍 FEATURE 2: Context-Aware Package Filter
### 2.1 Current State
`SubscriptionPlansView.vue` (frontend_app/src/views/SubscriptionPlansView.vue) at `/dashboard/subscription` **ALREADY implements** context-aware filtering:
```typescript
// Lines 197-205
const filteredPlans = computed(() => {
const isCorporate = authStore.isCorporateMode
return allPlans.value.filter((plan) => {
if (isCorporate) {
return plan.name.startsWith('corp_')
}
return plan.name.startsWith('private_')
})
})
```
The backend `GET /subscriptions/public` returns ALL public tiers. The comment at line 144 explicitly says: *"A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag)."*
### 2.2 Route Verification
- `FinanceMainView.vue:331-336` CTA → `/dashboard/subscription` (personal) or `/organization/{id}/subscription` (corporate)
- `FinancePackagesView.vue:37` CTA → `/dashboard/subscription`
- Both lead to `SubscriptionPlansView.vue` which has the correct filtering ✅
### 2.3 Enhancement: Add `rules.type` JSONB Check
The current filter uses name prefix matching only. For robustness, also check `plan.rules?.type`:
In `SubscriptionPlansView.vue` (frontend_app/src/views/SubscriptionPlansView.vue:197-205), replace the existing `filteredPlans`:
```typescript
const filteredPlans = computed(() => {
const isCorporate = authStore.isCorporateMode
return allPlans.value.filter((plan) => {
// Primary: check rules.type JSONB field
const rulesType = plan.rules?.type
if (rulesType === 'corporate' || rulesType === 'business') return isCorporate
if (rulesType === 'private' || rulesType === 'consumer') return !isCorporate
// Fallback: name prefix matching
if (isCorporate) {
return plan.name.startsWith('corp_') || plan.name.startsWith('business_')
}
return plan.name.startsWith('private_') || plan.name.startsWith('consumer_')
})
})
```
---
## 📊 Files Changed Summary
### Feature 1: CSS Flipping Card
| # | File | Change Type | ~Lines |
|---|------|-------------|--------|
| 1 | backend/app/api/v1/endpoints/users.py | Add `created_at`, `valid_from`, add-ons query, response fields | +45 |
| 2 | frontend_app/src/stores/auth.ts | Add 3 fields to `UserProfile` | +8 |
| 3 | frontend_app/src/views/FinanceMainView.vue | Flip card template + computed + CSS | Replace ~70L, +60 |
| 4 | frontend_app/src/i18n/hu.ts | 7 new i18n keys | +7 |
| 5 | frontend_app/src/i18n/en.ts | 7 new i18n keys | +7 |
### Feature 2: Context-Aware Package Filter
| # | File | Change Type | ~Lines |
|---|------|-------------|--------|
| 6 | frontend_app/src/views/SubscriptionPlansView.vue | Enhance `filteredPlans` computed | Replace L197-205 |
---
## 🧪 Testing Checklist
### Feature 1: Flip Card
- [ ] Card 2 flips on click (3D rotate Y, 0.7s transition)
- [ ] Front face shows: display_name, expiry, vehicle bar, CTA button
- [ ] Back face shows: registration date, valid_from, expiry, add-ons list
- [ ] CTA button click does NOT trigger flip (event propagation stopped via `@click.stop`)
- [ ] Clicking again flips back to front face
- [ ] Add-ons show individual status badges (Active/Expired)
- [ ] "No add-ons" message shown when `active_addons` is empty
- [ ] Card renders correctly at `h-[350px]` in the 4-column grid
- [ ] No visual regressions on Cards 1, 3, 4
### Feature 2: Package Filter
- [ ] In personal mode, only `private_*` packages are shown
- [ ] In corporate mode, only `corp_*` packages are shown
- [ ] `rules.type` JSONB field is respected as primary filter
- [ ] Name prefix fallback works when `rules.type` is absent
### Integration
- [ ] `sync_engine.py` runs without errors (no schema changes)
- [ ] `/auth/me` response includes new fields: `created_at`, `subscription_valid_from`, `active_addons`
- [ ] I18n keys work in both `hu` and `en` locales
---
## ⚠️ Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|-----------|
| `created_at` may be NULL on old user records | Low | Low | `v-if="userRegistrationDate"` guard in template |
| `valid_from` may be NULL (legacy data) | Low | Low | `v-if="subscriptionStartDate"` guard |
| User has no add-ons | High | Low | "Nincsenek aktív kiegészítők" fallback message |
| Tailwind v3 lacks `rotate-y-*` utilities | Certain | Medium | Inline `<style scoped>` CSS block (no config changes) |
| Flip card overflow at 350px | Low | Medium | Back face uses `overflow-y-auto` |
| Mobile grid collapse | Low | Low | Grid is responsive: `grid-cols-1 md:grid-cols-2 lg:grid-cols-4` |
| `rules.type` JSONB field may be absent | Medium | Low | Fallback to name prefix matching |
---
## 📎 References
- `SubscriptionTier` model — backend/app/models/core_logic.py:68
- `UserSubscription` model — backend/app/models/core_logic.py:174
- `OrganizationSubscription` model — backend/app/models/core_logic.py:143
- `User` model — backend/app/models/identity/identity.py:127
- `_build_user_response()` — backend/app/api/v1/endpoints/users.py:45
- `read_users_me()` — backend/app/api/v1/endpoints/users.py:147
- `GET /subscriptions/public` — backend/app/api/v1/endpoints/subscriptions.py:121
- `SubscriptionService` — backend/app/services/subscription_service.py:285
- `SubscriptionPlansView.vue` — frontend_app/src/views/SubscriptionPlansView.vue
- `FinanceMainView.vue` — frontend_app/src/views/FinanceMainView.vue
- `FinancePackagesView.vue` — frontend_app/src/views/FinancePackagesView.vue
- `appModeStore.ts` — frontend_app/src/stores/appModeStore.ts
- Earlier analysis — docs/subscription_card_upgrade_analysis.md
- Earlier spec — plans/logic_spec_subscription_card_upgrade.md

View File

@@ -0,0 +1,161 @@
# 🏗️ Végrehajtási Terv: Csomagváltási és Fizetési Anomáliák Javítása (4 fázis)
**Dátum:** 2026-07-28
**Szerző:** Technical Lead & System Architect
**Státusz:** 🔵 Tervezés kész — Gitea Issue-k létrehozva
**Kapcsolódó kártyák:** #429, #430, #431, #432
---
## 📋 Áttekintés
A felhasználó csomagot váltott (Ingyenesre), de a tranzakció adatbázis szinten nem ment végbe a `net_amount > 0` validációs hiba miatt. A javítást szigorú sorrendben, a mélyebb rétegektől (Backend/Adatbázis) a felület (Vue.js frontend) felé haladva kell végezni.
---
## 🔴 Fázis 1: Ingyenes csomag (0 Ft) bypass és Adatbázis szinkronizáció
**Gitea kártya:** [#429 - Issue #1: Ingyenes csomag (0 Ft) bypass és Adatbázis szinkronizáció javítása](https://gitea.profibot.hu/kincses/service-finder/issues/429)
### Root Cause
A [`FinancialManager.purchase_package()`](backend/app/services/financial_manager.py:176) mindig létrehoz egy PaymentIntent-et, még ingyenes (0 Ft) csomagok esetén is. A [`PaymentRouter.create_payment_intent()`](backend/app/services/payment_router.py:34) a 62. sorban elvégzi a `net_amount <= 0` validációt, ami ValueError-t dob.
### Megoldás
1. **Adatbázis modell bővítés:** `pending_tier_id` (FK) és `pending_activated_at` (DateTime) mezők hozzáadása a [`UserSubscription`](backend/app/models/core_logic.py:200) és [`OrganizationSubscription`](backend/app/models/core_logic.py:143) modellekhez.
2. **`is_downgrade()` metódus:** Új statikus metódus a [`SubscriptionService`](backend/app/services/subscription_service.py:74) osztályban, amely összehasonlítja a jelenlegi és a cél tier_level értékeket.
3. **Free tier bypass:** Ha `price <= 0`, a [`FinancialManager.purchase_package()`](backend/app/services/financial_manager.py:176) közvetlenül aktiválja a subscription-t, megkerülve a PaymentRouter-t.
4. **Downgrade logika:** Ha a cél tier_level < current tier_level → `pending_tier_id` beállítása, nem azonnali aktiválás. Upgrade esetén a pending flag törlése.
5. **Downgrade Executor:** Új szolgáltatás [`backend/app/services/downgrade_executor.py`](backend/app/services/downgrade_executor.py) a pending downgrade-ek aktiválására a `valid_until` lejárta után.
### Érintett fájlok
| Fájl | Módosítás |
|------|-----------|
| [`backend/app/models/core_logic.py`](backend/app/models/core_logic.py) | +2 oszlop (pending_tier_id, pending_activated_at) mindkét subscription modellben |
| [`backend/app/services/subscription_service.py`](backend/app/services/subscription_service.py) | Új `is_downgrade()` metódus |
| [`backend/app/services/financial_manager.py`](backend/app/services/financial_manager.py) | Free tier bypass + downgrade detektálás |
| `backend/app/services/downgrade_executor.py` | **ÚJ fájl** — CRON-jellegű pending aktiváló |
| [`backend/app/api/v1/endpoints/subscriptions.py`](backend/app/api/v1/endpoints/subscriptions.py) | `has_pending_downgrade` mezők a `/my` végpontban |
---
## 🟠 Fázis 2: Mock Payment Gateway (Szimulált Fizetés)
**Gitea kártya:** [#430 - Issue #2: Mock Payment Gateway (Szimulált Fizetés) implementálása](https://gitea.profibot.hu/kincses/service-finder/issues/430)
### Jelenlegi állapot
A [`MockPaymentGateway`](backend/app/services/mock_payment_gateway.py:31) három módot támogat: `auto_approve`, `simulate_failure`, `simulate_timeout`. Hiányzik a valósághű fizetési flow (redirect → webhook → completion).
### Megoldás
1. **Új mód: `simulate_redirect`** — Alapértelmezetté tenni. `create_intent()` visszaadja a `checkout_url`-t és a `requires_action` státuszt.
2. **Checkout oldal:** `GET /mock-payment/checkout/{intent_id}` — egyszerű HTML fizetési oldal "Pay Now" gombbal.
3. **Webhook callback:** `POST /mock-payment/callback` — PaymentIntent → COMPLETED státusz, subscription aktiválás.
4. **Logging:** Minden `create_intent()` hívásnál `MOCK_PAYMENT_REQUEST` logolás.
### Érintett fájlok
| Fájl | Módosítás |
|------|-----------|
| [`backend/app/services/mock_payment_gateway.py`](backend/app/services/mock_payment_gateway.py) | Új `simulate_redirect` mód |
| [`backend/app/api/v1/endpoints/billing.py`](backend/app/api/v1/endpoints/billing.py) | Új 2 végpont: checkout page + webhook callback |
---
## 🟡 Fázis 3: Subscription Details Card (Flip Card) adatkötés javítása
**Gitea kártya:** [#431 - Issue #3: Subscription Details Card adatkötésének javítása](https://gitea.profibot.hu/kincses/service-finder/issues/431)
### Jelenlegi állapot
A [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue:118-154) Card 2 a raw DB slug-ot (pl. `private_pro_v1`) jeleníti meg, nem mutat lejárati dátumot és járműhasználatot.
### Megoldás
1. **Backend:** [`GET /auth/me`](backend/app/api/v1/endpoints/users.py) bővítése `subscription_display_name`, `subscription_expires_at`, `subscription_tier_id` mezőkkel.
2. **Frontend:** [`auth.ts`](frontend_app/src/stores/auth.ts) `UserProfile` interface bővítése.
3. **Frontend:** [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) Card 2 template teljes átírása: fejléc, csomagnév, lejárati dátum, járműhasználati sáv, dinamikus CTA gomb.
4. **I18n:** `renewNow`, `upgradePlan` kulcsok felvétele.
### Érintett fájlok
| Fájl | Módosítás |
|------|-----------|
| [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py) | +subscription_display_name, +subscription_expires_at, +subscription_tier_id |
| [`frontend_app/src/stores/auth.ts`](frontend_app/src/stores/auth.ts) | `UserProfile` interface bővítése |
| [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) | Card 2 template + computed property rewrite |
| [`frontend_app/src/i18n/hu.ts`](frontend_app/src/i18n/hu.ts) | Új kulcsok |
| [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts) | Új kulcsok |
---
## 🟢 Fázis 4: Kiegészítő csomagok (Add-ons) kosár-logika
**Gitea kártya:** [#432 - Issue #4: Kiegészítő csomagok (Add-ons) kosár-logikájának és UI megjelenítésének integrálása](https://gitea.profibot.hu/kincses/service-finder/issues/432)
### Jelenlegi állapot
A [`SubscriptionTier`](backend/app/models/core_logic.py:68) modellben létezik a `type` mező (base/addon), de a frontend nem jeleníti meg az addon csomagokat, és nincs kosár-logika.
### Megoldás
1. **Backend:** [`GET /subscriptions/public`](backend/app/api/v1/endpoints/subscriptions.py:121) bővítése: `base_tiers` és `addon_tiers` külön mezők.
2. **Frontend:** [`SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue) base és addon csomagok szétválasztott megjelenítése.
3. **Kosár-logika:** [`PlanDetailsModal.vue`](frontend_app/src/components/subscription/PlanDetailsModal.vue) addon checkboxok + összegzés.
4. **Backend:** [`POST /purchase-package`](backend/app/api/v1/endpoints/financial_manager.py) bővítése `addon_tier_ids` fogadására.
### Érintett fájlok
| Fájl | Módosítás |
|------|-----------|
| [`backend/app/api/v1/endpoints/subscriptions.py`](backend/app/api/v1/endpoints/subscriptions.py) | `base_tiers` + `addon_tiers` szétválasztás |
| [`backend/app/api/v1/endpoints/financial_manager.py`](backend/app/api/v1/endpoints/financial_manager.py) | `addon_tier_ids` paraméter fogadása |
| [`frontend_app/src/views/SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue) | Base + addon szekciók |
| [`frontend_app/src/components/subscription/PlanDetailsModal.vue`](frontend_app/src/components/subscription/PlanDetailsModal.vue) | Addon checkboxok + kosár összegzés |
| [`frontend_app/src/i18n/hu.ts`](frontend_app/src/i18n/hu.ts) | Addon kulcsok |
| [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts) | Addon kulcsok |
---
## 📊 Függőségi Sorrend (Kritikus!)
```
Fázis 1 (Backend: Free tier bypass + pending)
Fázis 2 (Backend: Mock Payment Gateway)
Fázis 3 (Backend + Frontend: Card adatkötés)
Fázis 4 (Frontend: Add-on kosár)
```
**Miért ebben a sorrendben?**
1. **Fázis 1** nélkül a teljes csomagváltás el van törve (minden ingyenes csomagra váltás hibát dob)
2. **Fázis 2** nélkül a fizetési folyamat nem tesztelhető végponttól-végpontig
3. **Fázis 3** nélkül a frontend kártya nem mutat valós adatokat (még ha az API már helyes is)
4. **Fázis 4** csak azután jöhet, hogy a base csomagok kezelése stabil
---
## 🧪 Tesztelési Stratégia
### Egységtesztek
- `test_is_downgrade()` — tier_level összehasonlítás
- `test_free_tier_bypass()` — 0 Ft-os csomag nem hoz létre PaymentIntent-et
- `test_mock_redirect_mode()` — checkout_url generálás
- `test_mock_webhook_callback()` — callback feldolgozás
- `test_pending_downgrade_executor()` — pending tier aktiválás lejárat után
### Integrációs tesztek
- Teljes flow: Prémium vásárlás → azonnali aktiválás
- Teljes flow: Váltás Ingyenesre → pending_tier_id beállítás
- Teljes flow: Uprade pending downgrade alatt → pending törlés, új tier azonnal
- Addon vásárlás base csomaggal együtt
### E2E tesztek (Playwright)
- Pending downgrade banner megjelenése a SubscriptionStatusWidget-ban
- "Csomagváltás folyamatban" üzenet sikeres downgrade rendelés után
- Base + addon kosár összegzés helyes megjelenítése
---
## 📎 Referenciák
- [`logic_spec_subscription_downgrade_and_mock_payment.md`](plans/logic_spec_subscription_downgrade_and_mock_payment.md) — Teljes műszaki specifikáció
- [`logic_spec_subscription_card_upgrade.md`](plans/logic_spec_subscription_card_upgrade.md) — Card upgrade specifikáció
- [`logic_spec_subscription_package_filtering_bug.md`](plans/logic_spec_subscription_package_filtering_bug.md) — Package filtering bug specifikáció
- [P0 Subscription JSONB Audit](docs/p0_subscription_jsonb_structural_audit_report.md) — Tier JSONB struktúra audit

View File

@@ -0,0 +1,68 @@
# Plan: Fix Add-on Card Visibility & Styling
**Date:** 2026-07-28
**Status:** Ready for implementation
**Ref:** `docs/UI_STANDARDS.md` Section 6
## Problem
The add-on cards in [`SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue) use glass/dark-themed classes that make them invisible against the `garage-bg.png` background. The text is white/transparent on a semi-transparent emerald glass bg → completely unreadable.
## Target
Align add-on cards with the standard card pattern used by base plan cards (`BaseCard.vue`) and Finance cards (`FinanceMainView.vue`), but with a **green header** to differentiate them.
## Changes
### File: [`SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue)
The add-on card template currently at lines ~122-195 needs to be rewritten with solid white card styling:
**Add-on card wrapper (selected):**
```
bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden border-emerald-500 ring-1 ring-emerald-500/50 transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl
```
**Add-on card wrapper (unselected):**
```
bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden border-slate-200 transition-all duration-300 hover:border-slate-400 hover:-translate-y-2 hover:shadow-2xl
```
**Change summary:**
1. Replace the entire add-on card `<div>` inner structure to include a green gradient header bar
2. Change from dark glass classes to white solid classes
3. Update stat badges from `bg-white/10 text-white/50` to `bg-slate-50 text-slate-700`
4. Update toggle button (unselected) from `bg-white/10 text-white/80` to `bg-slate-100 text-slate-700 border border-slate-200`
5. Add `flex flex-col h-full` structure inside the card body
### File: No other files need changes
The PlanDetailsModal already has solid white styling and was not affected.
## Tailwind Classes Used
| Purpose | Classes |
|---|---|
| Card wrapper | `bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden` |
| Border (unselected) | `border-slate-200` |
| Border (selected) | `border-emerald-500 ring-1 ring-emerald-500/50` |
| Hover | `transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl` |
| Header bar | `h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 shrink-0 flex items-center px-4` |
| Header text | `text-white font-bold text-sm tracking-wide` |
| Body | `p-4 flex flex-col h-full text-slate-800` |
| Price (large) | `text-xl font-bold text-slate-900` |
| Price (small) | `text-slate-500 text-xs` |
| Stat badge | `bg-slate-50 rounded-lg p-2 text-center` |
| Stat number | `text-base font-bold text-slate-900` |
| Stat label | `text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider` |
| Toggle (selected) | `w-full py-2 rounded-xl text-sm font-semibold bg-emerald-600 hover:bg-emerald-700 text-white` |
| Toggle (unselected) | `w-full py-2 rounded-xl text-sm font-semibold bg-slate-100 hover:bg-emerald-50 text-slate-700 hover:text-emerald-700 border border-slate-200 hover:border-emerald-300` |
| Cart summary | Stays on dark bg (above add-on grid) — `bg-white/10 backdrop-blur-sm border border-white/20` is fine |
## Result
After fix:
- Add-on cards will have solid white backgrounds — readable on any background
- Green emerald headers visually distinguish them from base plan cards (dark blue headers)
- Same hover animation as all other cards
- Toggle buttons are recognizable as interactive elements (solid bg vs transparent)

View 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

View File

@@ -0,0 +1,597 @@
# 🏗️ Logic Spec: Subscription Delayed Downgrade & Mock Payment Simulation
**Date:** 2026-07-28
**Author:** Fast Coder (Core Developer)
**Status:** 🔵 Blueprint — Ready for Architect/Code Hand-off
**Masterbook Ref:** Master Book 2.0 / Epic 3: Financial Motor / Subscription Packages
---
## 1. 🎯 Objective
Design and implement two interconnected features:
1. **Delayed Downgrade Mechanism**: When a user switches to a cheaper/free tier, the change must only take effect **after the current, already-paid subscription period expires**. The user must be notified about this pending change.
2. **Mock Payment Gateway Simulation**: Upgrade the existing [`MockPaymentGateway`](backend/app/services/mock_payment_gateway.py:31) to properly simulate a full payment lifecycle (redirect to financial institution → async webhook callback → order completion), not just instant auto-approve.
---
## 2. 🔍 Root Cause Analysis: `net_amount pozitív szám kell legyen`
### 2.1 Error Source Pinpointed
The error originates from **two locations**:
| Location | File | Line | Trigger |
|----------|------|------|---------|
| **Payment Router** | [`backend/app/services/payment_router.py`](backend/app/services/payment_router.py:62) | L62-63 | `if net_amount <= 0: raise ValueError(...)` |
| **Billing API** | [`backend/app/api/v1/endpoints/billing.py`](backend/app/api/v1/endpoints/billing.py:70) | L70-71 | `if net_amount is None or net_amount <= 0: raise HTTPException(400, ...)` |
### 2.2 Why It Happens for Free Tier Switch
The current flow (via [`SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue:296) `handleOrder()`) calls:
```typescript
await api.post('/financial-manager/purchase-package', {
tier_id: plan.id, // e.g., Free tier (id=5)
org_id: orgId,
region_code: 'GLOBAL',
currency: 'EUR',
})
```
This goes to [`POST /financial-manager/purchase-package`](backend/app/api/v1/endpoints/financial_manager.py:128), which:
1. **Calculates price** → Free tier has `monthly_price: 0``price = 0.0`
2. **Calls `_create_payment_intent()`** → passes `amount=0.0` → calls [`PaymentRouter.create_payment_intent()`](backend/app/services/payment_router.py:34) with `net_amount=0.0`
3. **Validation fails** at L62: `if net_amount <= 0: raise ValueError("net_amount pozitív szám kell legyen")`
**The core issue:** The [`FinancialManager.purchase_package()`](backend/app/services/financial_manager.py:176) always creates a PaymentIntent, even for free ($0) tiers. The PaymentRouter requires `net_amount > 0`.
### 2.3 Current Flow Diagram (Broken)
```mermaid
graph TD
A[User clicks 'Megrendelés' on Free tier] --> B[SubscriptionPlansView.handleOrder]
B --> C[POST /financial-manager/purchase-package<br/>tier_id: 5, currency: EUR]
C --> D[FinancialManager.purchase_package]
D --> E[1. Validate tier ✓]
E --> F[2. Calculate price → 0.0]
F --> G[3. Create PaymentIntent<br/>net_amount=0.0]
G --> H[PaymentRouter.create_payment_intent]
H --> I{net_amount <= 0?}
I -->|YES| J[ValueError: net_amount pozitív szám kell legyen]
J --> K[PurchaseResult(success=False)]
K --> L[HTTP 400]
L --> M[Frontend shows alert with error]
```
---
## 3. 📐 Architecture: Current Purchase Flow (Working)
### 3.1 Full Flow for Paid Tier (Successful Case)
When a user buys a **paid** tier (e.g., Premium at €12.99):
```mermaid
sequenceDiagram
participant F as Frontend
participant FM as FinancialManager
participant PR as PaymentRouter
participant GW as MockPaymentGateway
participant SA as SubscriptionActivator
participant DB as Database
F->>FM: POST /purchase-package {tier_id: 16}
FM->>DB: 1. Validate tier exists
FM->>FM: 2. Calculate price (PricingCalculator)
Note over FM: Extracts from tier.rules.pricing_zones[DEFAULT].monthly_price
FM->>PR: 3. Create PaymentIntent (net_amount=12.99)
PR->>DB: INSERT PaymentIntent (PENDING)
FM->>GW: 4. process payment (create_intent)
GW-->>FM: {id: "mock_intent_xxx", status: "completed"}
FM->>DB: Mark PaymentIntent → COMPLETED
FM->>SA: 5. Activate subscription
SA->>DB: Deactivate old subscription(s)
SA->>DB: INSERT new User/OrgSubscription
FM->>DB: 6. Distribute commission
FM-->>F: PurchaseResult {success: true, ...}
```
### 3.2 Database Tables Touched
| Table | Schema | Operation | When |
|-------|--------|-----------|------|
| `subscription_tiers` | `system` | SELECT (validate tier) | Step 1 |
| `payment_intents` | `marketplace` | INSERT (PENDING) → UPDATE (COMPLETED) | Steps 3-4 |
| `user_subscriptions` / `org_subscriptions` | `finance` | UPDATE is_active=False (old) + INSERT (new) | Step 5 |
| `financial_ledger` | `finance` | INSERT (if real payment) | Step 4 |
| `users` | `identity` | UPDATE subscription_plan, subscription_expires_at | Step 5 |
---
## 4. 🛠️ Blueprint: Delayed Downgrade Mechanism
### 4.1 Design Decision: The `pending_plan_id` Column Approach
**Strategy:** Add two columns to both [`UserSubscription`](backend/app/models/core_logic.py:200) and [`OrganizationSubscription`](backend/app/models/core_logic.py:143) models:
```python
# New columns to add:
pending_tier_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("system.subscription_tiers.id"), nullable=True,
comment="If set, this is a pending downgrade that activates after current period expires"
)
pending_activated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="When the pending downgrade was requested (for audit trail)"
)
```
**Why this approach over alternatives:**
| Approach | Pros | Cons |
|----------|------|------|
| **pending_tier_id on existing subscription** (✅ Selected) | - No new table needed<br>- Simple query: `WHERE pending_tier_id IS NOT NULL`<br>- Easy migration<br>- Single source of truth for "what's active now" | - Slightly wider table |
| **New `pending_subscriptions` table** | - Clean separation | - JOIN overhead<br>- Complexity of syncing two tables<br>- Risk of inconsistency |
| **Separate inactive row with future date** | - Works with existing schema | - Hard to distinguish "pending" vs "expired"<br>- Conflicts with `is_active` logic |
| **JSONB `pending_downgrade` field** | - No schema migration | - Not queryable via FK<br>- No referential integrity<br>- Anti-pattern for relational data |
### 4.2 Business Logic Flow
```mermaid
graph TD
A[User switches to cheaper/free plan] --> B{Compare tier_level}
B -->|New tier_level >= Current tier_level| C[UPGRADE: Activate immediately]
B -->|New tier_level < Current tier_level| D[DOWNGRADE: Set pending_tier_id]
D --> E[Keep current subscription active until valid_until]
D --> F[Return response: 'Downgrade scheduled for DATE']
C --> G[Standard FinancialManager flow]
G --> H[Deactivate old + Activate new immediately]
E --> I[CRON / Middleware check:<br/>current_subscription.valid_until < now<br/>AND pending_tier_id IS NOT NULL]
I --> J[Activate pending tier<br/>Deactivate current<br/>Clear pending_tier_id]
J --> K[Notify user via notification service]
```
### 4.3 Implementation Steps
#### Step 1: Database Migration (Alembic)
Add `pending_tier_id` (FK → `system.subscription_tiers.id`) and `pending_activated_at` (DateTime) to both `finance.user_subscriptions` and `finance.org_subscriptions`.
Since we use the Custom Sync Engine, run:
```bash
docker exec -it sf_api python -m app.scripts.sync_engine
```
#### Step 2: Model Update
Update [`UserSubscription`](backend/app/models/core_logic.py:200-253) and [`OrganizationSubscription`](backend/app/models/core_logic.py:143-198) with new columns.
#### Step 3: Service Logic — `SubscriptionService` Enhancement
Add a new method to [`backend/app/services/subscription_service.py`](backend/app/services/subscription_service.py):
```python
@staticmethod
async def is_downgrade(
db: AsyncSession,
user_id: int,
target_tier_id: int,
active_org_id: Optional[int] = None,
) -> bool:
"""
Determine if switching to target_tier is a downgrade.
A downgrade occurs when:
- target_tier.tier_level < current_tier.tier_level
Returns True if this is a downgrade (delayed), False if upgrade (immediate).
"""
# Get current tier level
current_tier_name = await SubscriptionService.get_user_tier(db, user_id)
current_level = SubscriptionService._get_user_level(current_tier_name)
# Get target tier level
target_stmt = select(SubscriptionTier.tier_level).where(SubscriptionTier.id == target_tier_id)
target_result = await db.execute(target_stmt)
target_level = target_result.scalar_one_or_none()
if target_level is None:
raise ValueError(f"Tier {target_tier_id} not found")
return target_level < current_level
```
#### Step 4: FinancialManager — Downgrade Path in `purchase_package()`
In [`FinancialManager.purchase_package()`](backend/app/services/financial_manager.py:176), add a check after Step 1 (tier validation):
```python
# After tier validation (Step 1)
is_downgrade = await SubscriptionService.is_downgrade(
db, user_id, tier_id, org_id
)
if is_downgrade:
# ── Delayed downgrade path ──
# Skip payment (downgrade is free / $0)
# Set pending_tier_id on the current active subscription
# Return success with pending=True flag
...
```
#### Step 5: Downgrade Executor — CRON / Background Task
Create a new service [`backend/app/services/downgrade_executor.py`](backend/app/services/downgrade_executor.py) that:
1. Queries: `SELECT * FROM finance.user_subscriptions WHERE pending_tier_id IS NOT NULL AND valid_until < NOW()`
2. For each match: activate pending tier, deactivate current, clear pending fields
3. Same for `finance.org_subscriptions`
4. Logs all actions
5. Sends notification to user
#### Step 6: Frontend — Notification
The [`GET /subscriptions/my`](backend/app/api/v1/endpoints/subscriptions.py:229) endpoint already returns subscription data. Add:
```python
response_data["has_pending_downgrade"] = pending_tier_id is not None
response_data["pending_tier_name"] = pending_tier.name if pending_tier else None
response_data["pending_effective_date"] = current_subscription.valid_until.isoformat()
```
The frontend [`SubscriptionStatusWidget.vue`](frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue) would show:
```html
<div v-if="hasPendingDowngrade" class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-amber-800 text-sm font-medium">
📅 Csomagváltás folyamatban: {{ pendingTierName }}
(hatályos: {{ formattedPendingDate }})
</p>
</div>
```
---
## 5. 🛠️ Blueprint: Mock Payment Simulation Enhancement
### 5.1 Current State
The existing [`MockPaymentGateway`](backend/app/services/mock_payment_gateway.py:31) has three modes:
- `auto_approve` (default): Instant success
- `simulate_failure`: Always fails
- `simulate_timeout`: Returns "processing"
**Missing:** A realistic payment flow where:
1. A checkout URL/redirect is generated
2. The payment goes to "PENDING_PAYMENT" state
3. After an async callback (webhook), the payment is marked "PAID"
### 5.2 Desired Mock Payment Flow
```mermaid
sequenceDiagram
participant F as Frontend
participant FM as FinancialManager
participant GW as MockPaymentGateway
F->>FM: POST /purchase-package {tier_id, ...}
FM->>GW: create_intent(amount=12.99)
alt amount == 0 (Free tier)
GW-->>FM: {status: "approved", amount: 0}
FM->>FM: Activate subscription immediately
FM-->>F: {success: true, amount_paid: 0}
else amount > 0 (Paid tier - NEW simulate mode)
GW-->>FM: {status: "requires_action", <br/>checkout_url: "http://mock-gateway/checkout/xxx", <br/>intent_id: "mock_intent_xxx"}
FM-->>F: {success: true, <br/>checkout_url: "...", <br/>status: "PENDING_PAYMENT"}
Note over F: Frontend opens checkout_url<br/>in new tab / embedded webview
F->>GW: User clicks "Pay" on mock page
GW->>FM: Simulated webhook callback<br/>POST /billing/mock-webhook
FM->>DB: Update PaymentIntent → COMPLETED
FM->>SA: Activate subscription
FM-->>F: {success: true, <br/>redirect to success_url}
end
```
### 5.3 Implementation
#### Step 1: Add `simulate_redirect` mode to MockPaymentGateway
```python
def __init__(self, mode: str = "simulate_redirect", ...):
"""
Modes:
- simulate_redirect (NEW default): Returns checkout_url,
then processes via async webhook.
- auto_approve: Instant success (legacy).
- simulate_failure: Always fails.
"""
```
#### Step 2: Enhanced `create_intent()` for simulate_redirect
```python
async def create_intent(self, amount, currency, metadata, **kwargs):
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
logger.info(
"[MOCK_PAYMENT_REQUEST] Initiating transaction with external gateway "
"for amount: %s %s, intent_id=%s",
amount, currency, intent_id,
)
if self.mode == "simulate_redirect" and amount > 0:
# Return a checkout URL that the frontend can redirect to
base_url = kwargs.get("base_url", "http://localhost:8000")
return {
"id": intent_id,
"status": "requires_action",
"amount": float(amount),
"currency": currency,
"checkout_url": f"{base_url}/mock-payment/checkout/{intent_id}",
"method": "GET",
"gateway": "mock",
"metadata": metadata or {},
}
# ... existing logic for other modes ...
```
#### Step 3: Mock Checkout Page & Webhook Endpoint
**Mock Checkout Page** ([`backend/app/api/v1/endpoints/billing.py`](backend/app/api/v1/endpoints/billing.py) — add new route):
```python
@router.get("/mock-payment/checkout/{intent_id}")
async def mock_checkout_page(
intent_id: str,
db: AsyncSession = Depends(get_db),
):
"""
Mock payment checkout page.
Shows a simple HTML page with a "Pay Now" button.
When clicked, triggers the mock webhook callback.
"""
# Look up the PaymentIntent by stripe_session_id (= intent_id)
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
return HTMLResponse("Payment not found", status_code=404)
# Simple HTML form that POSTs to the mock success callback
html = f"""
<!DOCTYPE html>
<html>
<head><title>Mock Payment Gateway</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>🪙 Mock Payment Gateway</h1>
<div style="border: 1px solid #ddd; padding: 30px; max-width: 400px; margin: 0 auto;">
<p>Amount: <strong>{float(payment_intent.gross_amount)} {payment_intent.currency}</strong></p>
<p>Intent: {intent_id}</p>
<form action="/api/v1/billing/mock-payment/callback" method="POST">
<input type="hidden" name="intent_id" value="{intent_id}">
<button type="submit" style="padding: 12px 40px; background: #22c55e;
color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer;">
✅ Pay Now
</button>
</form>
</div>
</body>
</html>
"""
return HTMLResponse(html)
```
**Mock Webhook Callback:**
```python
@router.post("/mock-payment/callback")
async def mock_payment_callback(
intent_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""
Mock payment gateway callback.
Simulates the webhook that a real payment gateway would send
after successful payment. Updates PaymentIntent to COMPLETED
and triggers subscription activation.
"""
logger.info(
"[MOCK_PAYMENT_CALLBACK] Received callback for intent: %s",
intent_id,
)
# Look up the PaymentIntent
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
return JSONResponse(
{"error": "PaymentIntent not found or not PENDING"},
status_code=404,
)
# Update status to COMPLETED
payment_intent.status = PaymentIntentStatus.COMPLETED
payment_intent.completed_at = datetime.utcnow()
# Activate the subscription (if we have metadata about the tier)
metadata = payment_intent.metadata or {}
tier_id = metadata.get("tier_id")
user_id = metadata.get("user_id")
if tier_id and user_id:
activator = SubscriptionActivator()
await activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id,
)
await db.commit()
logger.info(
"[MOCK_PAYMENT_CALLBACK] Payment completed: intent=%s, amount=%s",
intent_id, float(payment_intent.gross_amount),
)
# Return a redirect to the frontend success page
return RedirectResponse(
url=f"{settings.FRONTEND_URL}/dashboard/subscription?payment=success",
status_code=302,
)
```
#### Step 4: Update FinancialManager for Free Tier ($0 bypass)
In [`FinancialManager.purchase_package()`](backend/app/services/financial_manager.py:176), add bypass logic:
```python
# ── Step 2b: If price is 0 (Free tier), bypass payment entirely ──
if price <= 0:
logger.info(
"Free tier detected (price=0). Bypassing payment for user_id=%d tier_id=%d",
user_id, tier_id,
)
# Activate subscription directly (no PaymentIntent needed)
if org_id:
subscription = await self.subscription_activator.activate_org_subscription(
db=db, org_id=org_id, tier_id=tier_id, duration_days=duration_days,
)
is_org = True
else:
subscription = await self.subscription_activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id, duration_days=duration_days,
)
is_org = False
await db.commit()
return PurchaseResult(
success=True,
subscription_id=subscription.id,
tier_name=tier.name,
valid_from=subscription.valid_from,
valid_until=subscription.valid_until,
amount_paid=0,
currency=currency,
gateway="none",
is_org_subscription=is_org,
)
```
---
## 6. 📋 Files Changed Summary
### Phase 1: Database Schema
| # | File | Change | Risk |
|---|------|--------|------|
| 1 | [`backend/app/models/core_logic.py`](backend/app/models/core_logic.py:200) | Add `pending_tier_id`, `pending_activated_at` to `UserSubscription` | Low - new nullable columns |
| 2 | [`backend/app/models/core_logic.py`](backend/app/models/core_logic.py:143) | Add same columns to `OrganizationSubscription` | Low - mirror change |
### Phase 2: Backend Services
| # | File | Change | Risk |
|---|------|--------|------|
| 3 | [`backend/app/services/subscription_service.py`](backend/app/services/subscription_service.py:74) | Add `is_downgrade()` static method | Low |
| 4 | [`backend/app/services/financial_manager.py`](backend/app/services/financial_manager.py:176) | Add free-tier bypass + downgrade detection path | Medium - core logic change |
| 5 | [`backend/app/services/mock_payment_gateway.py`](backend/app/services/mock_payment_gateway.py:31) | Add `simulate_redirect` mode with checkout URL | Low |
| 6 | **NEW** `backend/app/services/downgrade_executor.py` | Cron-based pending downgrade activator | Medium - new service |
### Phase 3: API Endpoints
| # | File | Change | Risk |
|---|------|--------|------|
| 7 | [`backend/app/api/v1/endpoints/billing.py`](backend/app/api/v1/endpoints/billing.py) | Add `GET /mock-payment/checkout/{intent_id}` + `POST /mock-payment/callback` | Low - new endpoints |
| 8 | [`backend/app/api/v1/endpoints/subscriptions.py`](backend/app/api/v1/endpoints/subscriptions.py:229) | Add `has_pending_downgrade`, `pending_tier_name`, `pending_effective_date` to `/my` response | Low |
| 9 | [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:148) | Add pending downgrade fields to `/auth/me` response | Low |
### Phase 4: Frontend
| # | File | Change | Risk |
|---|------|--------|------|
| 10 | [`frontend_app/src/stores/auth.ts`](frontend_app/src/stores/auth.ts) | Add `has_pending_downgrade`, `pending_tier_name`, `pending_effective_date` to `UserProfile` | Low |
| 11 | [`frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue) | Add pending downgrade banner | Low |
| 12 | [`frontend_app/src/views/SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue) | Show "Downgrade scheduled" message after successful order | Low |
### Phase 5: I18n
| # | File | Change | Risk |
|---|------|--------|------|
| 13 | [`frontend_app/src/i18n/hu.ts`](frontend_app/src/i18n/hu.ts) | Add `downgradePending`, `downgradeEffectiveDate`, `downgradeScheduled` keys | Low |
| 14 | [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts) | Add English translations | Low |
---
## 7. ⚠️ Risk Assessment & Edge Cases
| Risk | Impact | Mitigation |
|------|--------|------------|
| **Free tier bypass forgets to update User.subscription_plan** | Stale data on auth/me | Explicitly update both `UserSubscription` AND `User.subscription_plan` field |
| **Downgrade executor runs multiple times for same record** | Duplicate activation race condition | Use `FOR UPDATE SKIP LOCKED` or `is_active=False` check + atomic UPDATE with WHERE `pending_tier_id IS NOT NULL` |
| **User upgrades while pending downgrade exists** | Conflicting pending state | On upgrade: clear `pending_tier_id`, activate new tier immediately |
| **User cancels account before pending downgrade activates** | Orphan pending record | `pending_tier_id` is a nullable FK → ON DELETE SET NULL (or just leave it, no harm) |
| **Mock webhook never called (user closes browser)** | PaymentIntent stuck in PENDING | Add expiry to PaymentIntent → cron marks as FAILED after 24h |
| **$0 but with add-ons** | Free tier bypass skips add-on handling | Check `active_addons` and handle separately |
| **Stacking + downgrade conflict** | User with stacked time remaining | Downgrade takes effect after ALL stacked time expires (valid_until is the definitive date) |
---
## 8. 🧪 Testing Strategy
### Unit Tests
- `test_is_downgrade()`: Verify tier_level comparison (free→premium = upgrade, premium→free = downgrade)
- `test_free_tier_bypass()`: Verify `purchase_package()` with $0 price skips PaymentIntent creation
- `test_mock_redirect_mode()`: Verify `create_intent()` returns `checkout_url` in redirect mode
- `test_mock_webhook_callback()`: Verify callback updates PaymentIntent to COMPLETED
### Integration Tests
- Full flow: User buys Premium → immediately activated
- Full flow: User switches to Free → `pending_tier_id` set on existing subscription
- Full flow: User upgrades while pending → pending cleared, new tier immediate
- Downgrade executor: Simulate `valid_until` in past → verify pending tier activates
### E2E Tests
- Frontend: Verify pending downgrade banner appears in SubscriptionStatusWidget
- Frontend: Verify "Csomagváltás folyamatban" message after successful downgrade order
---
## 9. 📎 References
- [`SubscriptionTier` model](backend/app/models/core_logic.py:68) — `system.subscription_tiers` with `tier_level`
- [`UserSubscription` model](backend/app/models/core_logic.py:200) — `finance.user_subscriptions`
- [`OrganizationSubscription` model](backend/app/models/core_logic.py:143) — `finance.org_subscriptions`
- [`SubscriptionService`](backend/app/services/subscription_service.py:74) — Tier resolution logic
- [`FinancialManager`](backend/app/services/financial_manager.py:141) — Purchase orchestrator
- [`MockPaymentGateway`](backend/app/services/mock_payment_gateway.py:31) — Current mock gateway
- [`PaymentRouter.create_payment_intent()`](backend/app/services/payment_router.py:34) — Where `net_amount` validation lives
- [`PurchaseRequest` schema](backend/app/schemas/financial_manager.py:31) — API input schema
- [`PurchaseResponse` schema](backend/app/schemas/financial_manager.py:48) — API response schema
- [`SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue:296) — Frontend `handleOrder()` function
- [`PlanDetailsModal.vue`](frontend_app/src/components/subscription/PlanDetailsModal.vue) — Purchase modal
- [P0 Subscription JSONB Audit](docs/p0_subscription_jsonb_structural_audit_report.md) — Tier JSONB structure verified
- [logic_spec_subscription_card_upgrade.md](plans/logic_spec_subscription_card_upgrade.md) — Previous subscription card work

View File

@@ -0,0 +1,174 @@
# 🐛 Logic Spec: Subscription Package Filtering Bug (Private vs Corporate)
**Date:** 2026-07-28
**Status:** 🔍 Analyze Complete — Awaiting Code Implementation
**Severity:** P1 — Incorrect business logic visible to end users
---
## 🔬 Root Cause Analysis
### The Core Bug: `isCorporateMode` is too coarse
File: [`auth.ts`](dev/service_finder/frontend_app/src/stores/auth.ts:741-743)
```ts
const isCorporateMode = computed(() => {
return !!user.value?.active_organization_id
})
```
**Problem:** The `isCorporateMode` getter uses a simple `!!active_organization_id` check. But `active_organization_id` can be set to ANY organization type — including `individual` (private) organizations. The user's `active_organization_id` persists across sessions via the backend's `scope_id` field. When a user has an individual org set as active, `isCorporateMode` returns `true`, causing all downstream filtering to behave incorrectly.
### How the bug manifests
1. **`SubscriptionPlansView.vue`** (line 197-209) uses `authStore.isCorporateMode` to filter plans:
- When `isCorporateMode` is `true`: shows only `corporate`/`business` type packages
- When `isCorporateMode` is `false`: shows only `private`/`consumer` type packages
2. **`FinanceMainView.vue`** (line 416-421) uses `isCorporateMode` to compute the CTA route:
- When `true`: routes to `/organization/:id/subscription`
- When `false`: routes to `/dashboard/subscription`
3. **`SubscriptionStatusWidget.vue`** (line 104-113) uses `isCorporateMode` to resolve which subscription plan to display:
- When `true`: shows the active org's `subscription_plan`
- When `false`: shows the user's `subscription_plan`
### The Headers Are Also Affected
- **`HeaderCompanySwitcher.vue`** (line 180-201): The `activeGarageName` computed property shows the active org name. If on dashboard with an active individual org, it shows the org name instead of a generic "My Garage" label, which is confusing.
- The `OrganizationLayout.vue` (line 10) appends "garázsa" suffix regardless of org type. This is only visible on `/organization/:id/...` routes, so it's less critical.
### Backend: No Server-Side Filtering
File: [`subscriptions.py`](dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py:132-180)
The `GET /subscriptions/public` endpoint returns ALL public tiers without any `rules.type` filtering. The comment on line 144 explicitly states:
> "A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag)."
This puts the entire filtering burden on the frontend, which is where the bug lives. The backend should also filter as defense-in-depth.
---
## 🎯 Fix Plan
### Fix 1 (PRIMARY): Refine `isCorporateMode` in `auth.ts`
**File:** [`auth.ts`](dev/service_finder/frontend_app/src/stores/auth.ts) — lines 741-743
**Current code:**
```ts
const isCorporateMode = computed(() => {
return !!user.value?.active_organization_id
})
```
**New code:**
```ts
const isCorporateMode = computed(() => {
if (!user.value?.active_organization_id) return false
// Find the active org and check its type
const activeOrg = myOrganizations.value.find(
(org) => org.organization_id === user.value?.active_organization_id
)
if (!activeOrg) {
// If org data isn't loaded yet but we have an active_organization_id,
// fall back to the legacy behavior (conservative: assume corporate)
return true
}
// Individual orgs are NOT corporate mode
return activeOrg.org_type !== 'individual'
})
```
**Why this works:**
- An `active_organization_id` pointing to an `individual` type org means the user is in private mode
- Only business-type orgs (`business`, `fleet_owner`, etc.) trigger corporate mode
- Fallback is conservative: if org data isn't yet loaded, assume corporate to avoid accidentally leaking corporate packages to private users
### Fix 2 (DEFENSE-IN-DEPTH): Backend filtering in `subscriptions.py`
**File:** [`subscriptions.py`](dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py) — lines 132-180
Add a `rules.type` filter to the `get_public_subscriptions` endpoint. The backend can determine the user's context from the `active_organization_id` on the current user's `Organization` record.
**Pseudo-code approach:**
```python
# Determine if user is in corporate context
is_corporate = False
if active_org_id:
org_stmt = select(Organization.org_type).where(Organization.id == active_org_id)
org_type = (await db.execute(org_stmt)).scalar_one_or_none()
is_corporate = org_type and org_type != 'individual'
# After fetching tiers, filter by type:
for t in tiers:
rules = t.rules or {}
if not rules.get("lifecycle", {}).get("is_public", True):
continue
# NEW: Filter by target type
rules_type = rules.get("type", "private")
if is_corporate and rules_type not in ('corporate', 'business'):
continue
if not is_corporate and rules_type in ('corporate', 'business'):
continue
# ... continue with pricing resolution
```
### Fix 3 (UI CLARITY): Refine `activeGarageName` in `HeaderCompanySwitcher.vue`
**File:** [`HeaderCompanySwitcher.vue`](dev/service_finder/frontend_app/src/components/header/HeaderCompanySwitcher.vue) — lines 180-201
When the user is on the dashboard with an active individual org, the header should show a generic "My Garage" label rather than the org's name. The current code already has a fallback for this on line 200 (`return t('header.garageSelector')`), but it only triggers when no active org matches. We need to also check the org type:
```ts
const activeGarageName = computed(() => {
if (isOnCompanyGarage.value && authStore.user?.active_organization_id) {
const activeOrg = authStore.myOrganizations.find(...)
if (activeOrg) return activeOrg.display_name || activeOrg.name || ...
}
if (!isOnCompanyGarage.value && authStore.user?.active_organization_id) {
const activeOrg = authStore.myOrganizations.find(...)
if (activeOrg && activeOrg.org_type !== 'individual') {
// Only show org name for business-type orgs
return activeOrg.display_name || activeOrg.name || ...
}
// For individual orgs on dashboard, show generic label
return t('header.garageSelector')
}
return t('header.garageSelector')
})
```
---
## 📊 Impact Analysis
| Component | Current Behavior | Fixed Behavior |
|-----------|-----------------|----------------|
| `authStore.isCorporateMode` | `true` whenever ANY org is active | `true` only when a business-type org is active |
| `SubscriptionPlansView` filtering | Shows corporate packages for individual org users | Shows private packages for individual org users |
| `FinanceMainView` CTA route | Routes to org subscription for individual org users | Routes to dashboard subscription for individual org users |
| `SubscriptionStatusWidget` plan display | Shows org's plan (could be corporate) for individual org users | Shows user-level plan for individual org users |
| `HeaderCompanySwitcher` label | Shows org name even for individual orgs on dashboard | Shows generic "My Garage" for individual orgs on dashboard |
| Backend `GET /subscriptions/public` | Returns ALL tiers, no type filtering | Returns only tiers matching user's context |
---
## 🛡️ Regression Risk
| Risk | Mitigation |
|------|-----------|
| Users with mixed org types (individual + business) switching between them | The fix correctly distinguishes based on `org_type` field |
| `myOrganizations` not yet loaded when `isCorporateMode` is first evaluated | Conservative fallback: assume corporate if no org data loaded |
| Backend filtering changes could affect admin views | Admin endpoints use separate `/admin/packages` route, unaffected |
| Users who have NO active_organization_id set | `isCorporateMode` returns `false` — unchanged, safe |
---
## 🔗 Dependencies
- **Bemenet:** [`auth.ts`](dev/service_finder/frontend_app/src/stores/auth.ts) Pinia store, [`subscriptions.py`](dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py) backend endpoint, [`OrganizationItem`](dev/service_finder/frontend_app/src/types/organization.ts) type
- **Kimenet:** [`SubscriptionPlansView.vue`](dev/service_finder/frontend_app/src/views/SubscriptionPlansView.vue), [`FinanceMainView.vue`](dev/service_finder/frontend_app/src/views/FinanceMainView.vue), [`SubscriptionStatusWidget.vue`](dev/service_finder/frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue), [`HeaderCompanySwitcher.vue`](dev/service_finder/frontend_app/src/components/header/HeaderCompanySwitcher.vue)