diff --git a/.roo/history.md b/.roo/history.md index 5b11fcf0..c619efea 100644 --- a/.roo/history.md +++ b/.roo/history.md @@ -47,3 +47,317 @@ A `POST /api/v1/expenses/` végpont 500-as hibát dobott insurance (biztosítás ### Documentation `docs/p0_expense_500_import_bugfix_2026-07-26.md` **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) diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py index 04afb263..ec0171fe 100755 --- a/backend/app/api/v1/endpoints/assets.py +++ b/backend/app/api/v1/endpoints/assets.py @@ -256,8 +256,15 @@ async def get_user_vehicles( # Use a raw SQL expression for JSONB boolean ordering order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST") - if current_user.scope_id is None: - # Personal mode: show vehicles in organizations where user is a member + # P0 BUGFIX (2026-07-28): Staff users (ADMIN, SUPERADMIN, MODERATOR, etc.) + # 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( OrganizationMember.user_id == current_user.id ) diff --git a/backend/app/api/v1/endpoints/billing.py b/backend/app/api/v1/endpoints/billing.py index 1cfeb11b..88c62e34 100755 --- a/backend/app/api/v1/endpoints/billing.py +++ b/backend/app/api/v1/endpoints/billing.py @@ -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 import select, func, and_ from typing import Optional, Dict, Any, List import logging import uuid +from decimal import Decimal from app.api.deps import get_db, get_current_user 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.stripe_adapter import stripe_adapter 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() logger = logging.getLogger(__name__) @@ -407,3 +411,302 @@ async def get_wallet_transactions( except Exception as e: logger.error(f"Tranzakció történet lekérdezési hiba: {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="

Payment not found or already processed

", + 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""" + + + + + Mock Payment Gateway + + + +
+
🧪 Mock Gateway — Development Mode
+

Complete Your Payment

+

This is a simulated payment page for testing

+ +
+ {amount:.2f} {currency} +
+ +
+
Intent ID
+
{intent_id[:20]}...
+
Description
+
Subscription Package Purchase
+
+ +
+ + +
+ + +
+ +""" + + 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", + }) diff --git a/backend/app/api/v1/endpoints/financial_manager.py b/backend/app/api/v1/endpoints/financial_manager.py index 2d06660a..fce24f11 100644 --- a/backend/app/api/v1/endpoints/financial_manager.py +++ b/backend/app/api/v1/endpoints/financial_manager.py @@ -42,7 +42,15 @@ def get_financial_manager() -> FinancialManager: """ 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 return FinancialManager(payment_gateway=StripeAdapter()) @@ -50,7 +58,7 @@ def get_financial_manager() -> FinancialManager: A FinancialManager instance with the configured payment gateway. """ return FinancialManager( - payment_gateway=MockPaymentGateway(mode="auto_approve"), + payment_gateway=MockPaymentGateway(mode="simulate_redirect"), ) diff --git a/backend/app/api/v1/endpoints/subscriptions.py b/backend/app/api/v1/endpoints/subscriptions.py index 90c5b396..a9fb96a0 100644 --- a/backend/app/api/v1/endpoints/subscriptions.py +++ b/backend/app/api/v1/endpoints/subscriptions.py @@ -139,28 +139,61 @@ async def get_public_subscriptions( **Szabály:** - 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. + - 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ó 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 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 = ( 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) ) result = await db.execute(stmt) tiers = result.scalars().all() - # 3. Build response with resolved pricing + # 4. Build response with resolved pricing and type filtering response: List[PublicSubscriptionTierResponse] = [] for t in tiers: rules = t.rules or {} if not rules.get("lifecycle", {}).get("is_public", True): 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 resolved = resolve_pricing(rules, country_code) resolved_pricing = None @@ -253,12 +286,57 @@ async def get_my_subscription( user_tier = await SubscriptionService.get_user_tier(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 { "subscription": subscription_data, "tier": user_tier, "features": feature_flags.get("features", {}), "expires_at": feature_flags.get("expires_at"), "source": source, + # ── P0 Pending downgrade fields ── + "has_pending_downgrade": has_pending_downgrade, + "pending_tier_name": pending_tier_name, + "pending_effective_date": pending_effective_date, } diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index d95f223c..f3e98587 100755 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -42,6 +42,155 @@ class NetworkResponse(BaseModel): 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: """ 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: pass - # ── P0: Resolve subscription limits ── - # Default fallback values - max_vehicles = 1 - max_garages = 1 + # ── P0: Resolve subscription limits via shared function ── + # P0 BUGFIX (2026-07-28): Previously hardcoded max_vehicles=1 / max_garages=1. + # Now uses _resolve_subscription_data() which queries the actual subscription_tier + # 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 { "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, "person_id": user.person_id, "role": role_key, - "subscription_plan": user.subscription_plan, - "max_vehicles": max_vehicles, - "max_garages": max_garages, + "subscription_plan": sub_data["subscription_plan"], + "max_vehicles": sub_data["max_vehicles"], + "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_id": str(active_org_id) if active_org_id else None, "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) # ── P0: Resolve real subscription limits from the assigned tier ── - from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription - max_vehicles = 1 - max_garages = 1 - - if active_org_id is not None: - # Try org-level subscription first - org_sub_stmt = ( - select(SubscriptionTier) - .select_from(OrganizationSubscription) - .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)) + # P0 BUGFIX (2026-07-28): Uses the shared _resolve_subscription_data() function + # instead of duplicating the subscription resolution logic inline. + # This fixes both /auth/me and /users/me in one place. + # + # _build_user_response() already calls _resolve_subscription_data() internally, + # so _build_user_response now returns all subscription fields (max_vehicles, + # subscription_display_name, etc.) in the base response. We just need to + # override is_last_admin and org_capabilities post-build. + sub_data = await _resolve_subscription_data(db, current_user, active_org_id) # ── RBAC Phase 3: Build base response ── # 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["is_last_admin"] = is_last_admin - response_data["max_vehicles"] = max_vehicles - response_data["max_garages"] = max_garages + # All subscription fields are already in response_data from _build_user_response # ── RBAC Phase 3: Resolve org_capabilities ── # Get all organizations the user is a member of diff --git a/backend/app/models/core_logic.py b/backend/app/models/core_logic.py index ec09ad84..043d9bea 100755 --- a/backend/app/models/core_logic.py +++ b/backend/app/models/core_logic.py @@ -164,12 +164,60 @@ class OrganizationSubscription(Base): # Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100} 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 ── tier: Mapped[Optional["SubscriptionTier"]] = relationship( "SubscriptionTier", foreign_keys=[tier_id], 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): """ @@ -193,12 +241,60 @@ class UserSubscription(Base): 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) + # ── 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 ── tier: Mapped[Optional["SubscriptionTier"]] = relationship( "SubscriptionTier", foreign_keys=[tier_id], 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): diff --git a/backend/app/schemas/financial_manager.py b/backend/app/schemas/financial_manager.py index 5dfbda1e..e5b4cf56 100644 --- a/backend/app/schemas/financial_manager.py +++ b/backend/app/schemas/financial_manager.py @@ -50,7 +50,8 @@ class PurchaseResponse(BaseModel): Response schema for a completed package purchase. 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") 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") gateway: str = Field("mock", description="The payment gateway used") 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") commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results") error: Optional[str] = Field(None, description="Error message if success=False") diff --git a/backend/app/schemas/subscription.py b/backend/app/schemas/subscription.py index 75f0d38d..b6978929 100644 --- a/backend/app/schemas/subscription.py +++ b/backend/app/schemas/subscription.py @@ -22,7 +22,7 @@ from datetime import datetime from decimal import Decimal 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 ────────────────────── @@ -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") +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): """Hirdetési szabályok a csomaghoz.""" 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 lifecycle: Életciklus állapotok (opcionális, pl. is_public) 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) marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color) """ @@ -131,6 +142,10 @@ class SubscriptionRulesModel(BaseModel): default=None, 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( default=None, 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.") 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 ─────────────────────────────────────────── @@ -222,6 +335,36 @@ class SubscriptionTierUpdate(BaseModel): 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): """Csomagok listázásának válaszformátuma (GET /).""" total: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f9545a24..cc5dacc5 100755 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,6 +1,6 @@ # /opt/docker/dev/service_finder/backend/app/schemas/user.py 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 datetime import date, datetime @@ -43,6 +43,17 @@ class UserResponse(UserBase): # P0: Real subscription limits from the assigned subscription_tier JSONB rules max_vehicles: 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_id: Optional[str] = None ui_mode: str = "personal" diff --git a/backend/app/services/downgrade_executor.py b/backend/app/services/downgrade_executor.py new file mode 100644 index 00000000..702bc509 --- /dev/null +++ b/backend/app/services/downgrade_executor.py @@ -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 diff --git a/backend/app/services/financial_manager.py b/backend/app/services/financial_manager.py index 20fec33c..42dabac0 100644 --- a/backend/app/services/financial_manager.py +++ b/backend/app/services/financial_manager.py @@ -51,6 +51,7 @@ from app.schemas.commission import ( CommissionDistributionResponse, ) from app.services import commission_service +from app.services.subscription_service import SubscriptionService logger = logging.getLogger("financial-manager") @@ -80,9 +81,11 @@ class PurchaseResult: currency: str = "EUR", gateway: str = "mock", gateway_intent_id: Optional[str] = None, + checkout_url: Optional[str] = None, commission_result: Optional[CommissionDistributionResponse] = None, error: Optional[str] = None, is_org_subscription: bool = False, + payment_status: Optional[str] = None, ): self.success = success self.payment_intent_id = payment_intent_id @@ -95,9 +98,11 @@ class PurchaseResult: self.currency = currency self.gateway = gateway self.gateway_intent_id = gateway_intent_id + self.checkout_url = checkout_url self.commission_result = commission_result self.error = error self.is_org_subscription = is_org_subscription + self.payment_status = payment_status def to_dict(self) -> Dict[str, Any]: """Serialize to a dict for API response.""" @@ -115,6 +120,12 @@ class PurchaseResult: "gateway_intent_id": self.gateway_intent_id, "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: result["commission"] = { "total_commission": self.commission_result.total_commission, @@ -225,6 +236,89 @@ class FinancialManager: # ── Step 2: Calculate price ──────────────────────────────────── 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 ─────────────────────────────── payment_intent = await self._create_payment_intent( db=db, @@ -242,15 +336,87 @@ class FinancialManager: "payment_intent_id": payment_intent.id, "tier_id": tier_id, "user_id": user_id, + "org_id": org_id, + "duration_days": duration_days, + "region_code": region_code, **(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 payment_intent.status = PaymentIntentStatus.COMPLETED payment_intent.stripe_session_id = gateway_result.get("id") 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 ────────────────────────────── if org_id: subscription = await self.subscription_activator.activate_org_subscription( @@ -281,7 +447,7 @@ class FinancialManager: await db.commit() 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", user_id, tier.name, price, subscription.id, @@ -300,6 +466,7 @@ class FinancialManager: currency=currency, gateway=type(self.payment_gateway).__name__, gateway_intent_id=gateway_result.get("id"), + payment_status="COMPLETED", commission_result=commission_result, is_org_subscription=is_org, ) @@ -517,3 +684,161 @@ class FinancialManager: ) # Commission failure should not block the purchase 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, + ) diff --git a/backend/app/services/mock_payment_gateway.py b/backend/app/services/mock_payment_gateway.py index 39911edc..921414df 100644 --- a/backend/app/services/mock_payment_gateway.py +++ b/backend/app/services/mock_payment_gateway.py @@ -33,37 +33,44 @@ class MockPaymentGateway(BasePaymentGateway): Mock payment gateway for development and testing. 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_timeout: Payments hang until a configurable timeout then succeed. Usage: - gateway = MockPaymentGateway(mode="auto_approve") + gateway = MockPaymentGateway(mode="simulate_redirect") result = await gateway.create_intent(Decimal("100.00"), "EUR") """ def __init__( self, - mode: str = "auto_approve", + mode: str = "simulate_redirect", failure_message: str = "Mock payment declined (simulated failure)", timeout_seconds: int = 5, + base_url: str = "http://localhost:8000", ): """ Initialize the mock gateway. 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. timeout_seconds: Simulated processing delay for simulate_timeout mode. + base_url: Base URL for generating mock checkout URLs. """ self.mode = mode self.failure_message = failure_message self.timeout_seconds = timeout_seconds + self.base_url = base_url self._processed_intents: Dict[str, Dict[str, Any]] = {} logger.info( - "MockPaymentGateway initialized: mode=%s, timeout=%ds", - self.mode, self.timeout_seconds, + "MockPaymentGateway initialized: mode=%s, timeout=%ds, base_url=%s", + self.mode, self.timeout_seconds, self.base_url, ) # ────────────────────────────────────────────────────────────────────────── @@ -80,28 +87,46 @@ class MockPaymentGateway(BasePaymentGateway): """ Create a mock payment intent. - In auto_approve mode, immediately returns a "completed" intent. - In simulate_failure mode, raises PaymentGatewayError. - In simulate_timeout mode, logs a warning and returns "processing". + Modes: + - simulate_redirect (default): For paid amounts (>0), returns a checkout URL + 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: amount: The payment amount. currency: ISO 4217 currency code (default: EUR). metadata: Optional metadata dict. - **kwargs: Additional parameters (ignored in mock). + **kwargs: Supports 'base_url' override for the checkout URL base. 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: PaymentGatewayError: In simulate_failure mode. """ intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}" + + # ── Strict console log for audit trail ────────────────────────────── logger.info( - "Mock create_intent: id=%s amount=%s %s mode=%s", - intent_id, amount, currency, self.mode, + "[MOCK_PAYMENT_REQUEST] Initiating transaction with external gateway " + "for amount: %s %s, mode=%s, intent_id=%s", + amount, currency, self.mode, intent_id, ) + # ── simulate_failure: Always fail ─────────────────────────────────── if self.mode == "simulate_failure": logger.warning( "Mock payment FAILURE: intent=%s reason='%s'", @@ -109,6 +134,7 @@ class MockPaymentGateway(BasePaymentGateway): ) raise PaymentGatewayError(self.failure_message) + # ── simulate_timeout: Pretend to hang then return "processing" ────── if self.mode == "simulate_timeout": logger.warning( "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" # 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() intent_data = { "id": intent_id, - "status": "completed" if self.mode == "auto_approve" else "processing", + "status": "completed", "amount": float(amount), "currency": currency, "created_at": now.isoformat(), - "completed_at": now.isoformat() if self.mode == "auto_approve" else None, + "completed_at": now.isoformat(), "metadata": metadata or {}, "gateway": "mock", } 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 async def verify_payment( @@ -228,9 +294,10 @@ class MockPaymentGateway(BasePaymentGateway): Change the gateway's operating mode at runtime. 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: raise ValueError( f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}" diff --git a/backend/app/services/subscription_service.py b/backend/app/services/subscription_service.py index cc68fff4..cedb55e8 100644 --- a/backend/app/services/subscription_service.py +++ b/backend/app/services/subscription_service.py @@ -333,6 +333,14 @@ class SubscriptionService: "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, "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", {}), "pricing": rules.get("pricing", {}), "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_until": user_sub.valid_until.isoformat() if user_sub.valid_until else None, "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", {}), "pricing": rules.get("pricing", {}), "duration": rules.get("duration", {}), @@ -471,6 +487,65 @@ class SubscriptionService: 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 def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool: """ diff --git a/docs/UI_STANDARDS.md b/docs/UI_STANDARDS.md new file mode 100644 index 00000000..703dbdc4 --- /dev/null +++ b/docs/UI_STANDARDS.md @@ -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* diff --git a/docs/finance_flip_card_and_filter_implementation.md b/docs/finance_flip_card_and_filter_implementation.md new file mode 100644 index 00000000..5fabd090 --- /dev/null +++ b/docs/finance_flip_card_and_filter_implementation.md @@ -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 diff --git a/docs/subscription_card_upgrade_analysis.md b/docs/subscription_card_upgrade_analysis.md new file mode 100644 index 00000000..83584396 --- /dev/null +++ b/docs/subscription_card_upgrade_analysis.md @@ -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 diff --git a/docs/subscription_info_modal_planDisplayName_bug_analysis.md b/docs/subscription_info_modal_planDisplayName_bug_analysis.md new file mode 100644 index 00000000..689fece9 --- /dev/null +++ b/docs/subscription_info_modal_planDisplayName_bug_analysis.md @@ -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 = { + '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 +}) +``` diff --git a/docs/subscription_limit_bug_root_cause_analysis.md b/docs/subscription_limit_bug_root_cause_analysis.md new file mode 100644 index 00000000..841aee61 --- /dev/null +++ b/docs/subscription_limit_bug_root_cause_analysis.md @@ -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 212–254. + +### 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 267–308), valamint a `subscription_display_name` feloldására (sor 311–312). + +### 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 | + diff --git a/frontend_admin/.nuxt/manifest/latest.json b/frontend_admin/.nuxt/manifest/latest.json index 0453bf19..ad04336c 100644 --- a/frontend_admin/.nuxt/manifest/latest.json +++ b/frontend_admin/.nuxt/manifest/latest.json @@ -1 +1 @@ -{"id":"dev","timestamp":1785068325212} \ No newline at end of file +{"id":"dev","timestamp":1785166100527} \ No newline at end of file diff --git a/frontend_admin/.nuxt/manifest/meta/dev.json b/frontend_admin/.nuxt/manifest/meta/dev.json index 6f1a5516..2b9e3f06 100644 --- a/frontend_admin/.nuxt/manifest/meta/dev.json +++ b/frontend_admin/.nuxt/manifest/meta/dev.json @@ -1 +1 @@ -{"id":"dev","timestamp":1785068325212,"prerendered":[]} \ No newline at end of file +{"id":"dev","timestamp":1785166100527,"prerendered":[]} \ No newline at end of file diff --git a/frontend_admin/.nuxt/nitro.json b/frontend_admin/.nuxt/nitro.json index e1ce5222..c131d26f 100644 --- a/frontend_admin/.nuxt/nitro.json +++ b/frontend_admin/.nuxt/nitro.json @@ -1,5 +1,5 @@ { - "date": "2026-07-26T12:18:53.544Z", + "date": "2026-07-27T15:28:28.473Z", "preset": "nitro-dev", "framework": { "name": "nuxt", @@ -11,7 +11,7 @@ "dev": { "pid": 19, "workerAddress": { - "socketPath": "\u0000nitro-worker-19-1-1-8775.sock" + "socketPath": "\u0000nitro-worker-19-1-1-1764.sock" } } } \ No newline at end of file diff --git a/frontend_admin/.nuxt/nuxt.d.ts b/frontend_admin/.nuxt/nuxt.d.ts index 17ecec00..67d1e477 100644 --- a/frontend_admin/.nuxt/nuxt.d.ts +++ b/frontend_admin/.nuxt/nuxt.d.ts @@ -1,8 +1,8 @@ -/// -/// -/// /// /// +/// +/// +/// /// /// /// diff --git a/frontend_admin/.nuxt/tailwind/postcss.mjs b/frontend_admin/.nuxt/tailwind/postcss.mjs index dd53a181..5ea7d277 100644 --- a/frontend_admin/.nuxt/tailwind/postcss.mjs +++ b/frontend_admin/.nuxt/tailwind/postcss.mjs @@ -1,4 +1,4 @@ -// generated by the @nuxtjs/tailwindcss module at 7/26/2026, 12:18:45 PM +// generated by the @nuxtjs/tailwindcss module at 7/28/2026, 12:21:03 PM import "@nuxtjs/tailwindcss/config-ctx" import configMerger from "@nuxtjs/tailwindcss/merger"; diff --git a/frontend_admin/pages/packages/index.vue b/frontend_admin/pages/packages/index.vue index c9b7c9b4..4cea32c2 100644 --- a/frontend_admin/pages/packages/index.vue +++ b/frontend_admin/pages/packages/index.vue @@ -704,6 +704,113 @@

{{ $t('packages.feature_capabilities_hint') }}

{{ featureCapabilitiesError }}

+ +
+ +
+
+
+

🔄 {{ $t('packages.renewal_title') || 'Auto-Renewal' }}

+

{{ $t('packages.renewal_subtitle') || 'Configure automatic subscription renewal rules for this package.' }}

+
+ +
+
+ + +
+ +
+
+
+

{{ $t('packages.renewal_auto_default') || 'Auto-Renew by Default' }}

+

{{ $t('packages.renewal_auto_default_hint') || 'New subscriptions will have auto-renew enabled by default.' }}

+
+ +
+
+ + +
+
+ + +

{{ $t('packages.renewal_period_hint') || 'If empty, the subscription duration.days is used.' }}

+
+
+ + +

{{ $t('packages.renewal_grace_period_hint') || 'Days after expiry before subscription suspension.' }}

+
+
+ + +
+

{{ $t('packages.renewal_retry_title') || 'Retry on Failure' }}

+
+
+
+

{{ $t('packages.renewal_retry') || 'Retry on Failure' }}

+

{{ $t('packages.renewal_retry_hint') || 'Retry failed payment attempts automatically.' }}

+
+ +
+
+ + +
+
+
+
+ + +
+

{{ $t('packages.renewal_disabled_hint') || 'Auto-renewal is disabled for this package. Toggle the switch above to enable it.' }}

+
+
@@ -825,6 +932,14 @@ interface SubscriptionTier { is_public?: boolean available_until?: string | 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 tier_level: number @@ -855,6 +970,13 @@ interface PackageForm { trial_days_on_signup: number feature_capabilities_json: string pricing_zones: Record + // ── 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 ────────────────────────────────────────────── @@ -863,6 +985,7 @@ const tabs = [ { key: 'basic', label: '📋 Basic Info' }, { key: 'pricing', label: '💰 Pricing' }, { key: 'features', label: '⚡ Features' }, + { key: 'renewal', label: '🔄 Auto-Renewal' }, ] const activeTab = ref('basic') @@ -956,7 +1079,14 @@ const defaultForm: PackageForm = { is_default_fallback: false, trial_days_on_signup: 0, 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({ ...defaultForm }) @@ -1181,6 +1311,15 @@ function openEditModal(pkg: SubscriptionTier) { // Load feature capabilities as JSON string 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 } @@ -1301,6 +1440,15 @@ async function savePackage() { lifecycle: { 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) { diff --git a/frontend_app/src/api/axios.ts b/frontend_app/src/api/axios.ts index c9f85fa1..7bc0055f 100644 --- a/frontend_app/src/api/axios.ts +++ b/frontend_app/src/api/axios.ts @@ -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( (response) => response, (error) => { @@ -30,6 +30,12 @@ api.interceptors.response.use( localStorage.removeItem('access_token') // 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) } ) diff --git a/frontend_app/src/components/cost/CostEntryWizard.vue b/frontend_app/src/components/cost/CostEntryWizard.vue index be92c573..aac7b684 100644 --- a/frontend_app/src/components/cost/CostEntryWizard.vue +++ b/frontend_app/src/components/cost/CostEntryWizard.vue @@ -679,7 +679,8 @@ const FUEL_SUBCATEGORY_CODES = new Set(['OPERATION_FUEL', 'OPERATION_ELECTRIC']) */ const selectedSubCategoryCode = computed(() => { 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)) 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() { try { // Pass visibility=b2c so only user-facing categories are returned const res = await api.get('/dictionaries/cost-categories', { 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) window.__allCostCategories = allCategories } catch (err) { @@ -821,7 +837,8 @@ function onMainCategoryChange() { 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( (c) => c.parent_id === Number(form.mainCategory) ) diff --git a/frontend_app/src/components/dashboard/ComplexExpenseModal.vue b/frontend_app/src/components/dashboard/ComplexExpenseModal.vue index adbfb818..0cbae8fa 100644 --- a/frontend_app/src/components/dashboard/ComplexExpenseModal.vue +++ b/frontend_app/src/components/dashboard/ComplexExpenseModal.vue @@ -223,10 +223,24 @@ const vehicleLabel = computed(() => { /** * 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() { try { 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 mainCategories.value = allCategories.filter((c) => c.parent_id === null) @@ -255,7 +269,8 @@ function onMainCategoryChange() { 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( (c) => c.parent_id === Number(form.mainCategory) ) @@ -296,7 +311,8 @@ async function handleSubmit() { if (props.isFeeMode) { // Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID 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) categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails } else { diff --git a/frontend_app/src/components/dashboard/CostsActionsCard.vue b/frontend_app/src/components/dashboard/CostsActionsCard.vue index 51e86985..001abe9e 100644 --- a/frontend_app/src/components/dashboard/CostsActionsCard.vue +++ b/frontend_app/src/components/dashboard/CostsActionsCard.vue @@ -33,9 +33,18 @@
- +
+
+

{{ t('dashboard.loading') }}

+
+ + +
⚠️ {{ t('dashboard.noVehicleSelected') }} diff --git a/frontend_app/src/components/dashboard/MyVehiclesCard.vue b/frontend_app/src/components/dashboard/MyVehiclesCard.vue index 17d8559f..edcfbebe 100644 --- a/frontend_app/src/components/dashboard/MyVehiclesCard.vue +++ b/frontend_app/src/components/dashboard/MyVehiclesCard.vue @@ -16,25 +16,36 @@
- - +
- - - -

{{ t('dashboard.noVehicles') }}

+
+

{{ t('dashboard.loading') }}

+ +
diff --git a/frontend_app/src/components/dashboard/SimpleFuelModal.vue b/frontend_app/src/components/dashboard/SimpleFuelModal.vue index b02039d1..2dd915be 100644 --- a/frontend_app/src/components/dashboard/SimpleFuelModal.vue +++ b/frontend_app/src/components/dashboard/SimpleFuelModal.vue @@ -368,10 +368,24 @@ function autoCalculate() { // ── Category Resolution ── const fuelCategoryId = ref(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() { try { 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) if (fuelCat) { fuelCategoryId.value = fuelCat.id diff --git a/frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue b/frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue index c2c82df7..f69f273e 100644 --- a/frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue +++ b/frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue @@ -27,7 +27,7 @@ - {{ subscriptionPlan }} + {{ subscriptionDisplayName }} { 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 = { + '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. * 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' }) } -onMounted(async () => { - // Ensure organizations are loaded - if (authStore.myOrganizations.length === 0) { - try { - await authStore.fetchMyOrganizations() - } catch { - // Silently fail - } - } - // Ensure vehicles are loaded - if (vehicleStore.vehicles.length === 0) { - try { - await vehicleStore.fetchVehicles() - } catch { - // Silently fail - } - } +/** + * P0 BUGFIX (2026-07-28): Admin Loading Loop — removed redundant data fetches. + * + * ROOT CAUSE: SubscriptionStatusWidget.onMounted() called vehicleStore.fetchVehicles() + * when vehicles.length === 0. This set vehicleStore.isLoading = true, which caused + * DashboardView to show the spinner and destroy the grid (v-if="!isLoading"). + * When the fetch completed, isLoading = false, the grid re-mounted, SubscriptionStatusWidget + * mounted fresh, checked vehicles.length === 0 again, re-fetched → INFINITE LOOP. + * + * FIX: The parent DashboardView already calls authStore.fetchMyOrganizations() and + * vehicleStore.fetchVehicles() in its own onMounted(). This widget should only + * set its local isLoading to false — it should NOT trigger its own data fetches. + */ +onMounted(() => { isLoading.value = false }) diff --git a/frontend_app/src/components/header/HeaderCompanySwitcher.vue b/frontend_app/src/components/header/HeaderCompanySwitcher.vue index 2e480a07..ed922fad 100644 --- a/frontend_app/src/components/header/HeaderCompanySwitcher.vue +++ b/frontend_app/src/components/header/HeaderCompanySwitcher.vue @@ -187,14 +187,17 @@ const activeGarageName = computed(() => { 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) { const activeOrg = authStore.myOrganizations.find( (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') } + // Individual (private) orgs on dashboard: show "Saját Garázs" / "Private Garage" + return t('header.privateGarage') } // Default: show personal garage label return t('header.garageSelector') diff --git a/frontend_app/src/components/provider/ProviderQuickAddModal.vue b/frontend_app/src/components/provider/ProviderQuickAddModal.vue index b8c4c635..74cfc3ec 100644 --- a/frontend_app/src/components/provider/ProviderQuickAddModal.vue +++ b/frontend_app/src/components/provider/ProviderQuickAddModal.vue @@ -662,11 +662,25 @@ function onFormFieldChange() { }, 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) ── async function fetchCategories() { try { const res = await api.get('providers/categories') - categories.value = res.data || [] + categories.value = normalizeCategories(res.data) } catch { categories.value = [ { 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.address_zip, onFormFieldChange) -// ── Load categories on mount ── -onMounted(() => { - fetchCategories() - loadCategoryTree() -}) - -// 🔍 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)) - } -}) +// P0 BUGFIX (2026-07-28): Removed onMounted pre-fetch and debug watchEffect. +// loadCategoryTree() and fetchCategories() are already called in the +// watch(() => props.isOpen) handler (line 946-959), so pre-fetching on +// mount is redundant AND harmful — it triggers API calls even when the +// modal is closed, contributing to the infinite admin reactivity loop. diff --git a/frontend_app/src/views/SubscriptionPlansView.vue b/frontend_app/src/views/SubscriptionPlansView.vue index 6b14f586..ea2814d6 100644 --- a/frontend_app/src/views/SubscriptionPlansView.vue +++ b/frontend_app/src/views/SubscriptionPlansView.vue @@ -6,10 +6,24 @@
- -
-

{{ t('subscription.title') }}

-

{{ t('subscription.subtitle') }}

+ +
+ + + + +
+

{{ t('subscription.title') }}

+

{{ t('subscription.subtitle') }}

+
@@ -34,121 +48,257 @@
- -
- +
+ +
- - - - -
- -

- {{ plan.rules.marketing.subtitle }} -

- - -
-
- {{ formatPrice(resolvedMonthlyPrice(plan)) }} - /{{ t('subscription.month') }} -
- -
- {{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }} - - ({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }}) + + + - -
-
-
- {{ plan.rules?.allowances?.max_vehicles ?? '—' }} -
-
- {{ t('subscription.maxVehicles') }} -
-
-
-
- {{ plan.rules?.allowances?.max_garages ?? '—' }} -
-
- {{ t('subscription.maxGarages') }} -
-
-
-
- {{ plan.rules?.allowances?.monthly_free_credits ?? '—' }} -
-
- {{ t('subscription.freeCredits') }} -
-
-
+ +
+ +

+ {{ plan.rules.marketing.subtitle }} +

- -
- - -
-
- {{ t('subscription.currentPlan') }} + +
+
+ {{ formatPrice(resolvedMonthlyPrice(plan)) }} + /{{ t('subscription.month') }} +
+ +
+ {{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }} + + ({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }}) + +
+ + +
+
+
+ {{ plan.rules?.allowances?.max_vehicles ?? '—' }} +
+
+ {{ t('subscription.maxVehicles') }} +
+
+
+
+ {{ plan.rules?.allowances?.max_garages ?? '—' }} +
+
+ {{ t('subscription.maxGarages') }} +
+
+
+
+ {{ plan.rules?.allowances?.monthly_free_credits ?? '—' }} +
+
+ {{ t('subscription.freeCredits') }} +
+
+
+ + +
+ + +
+
+ {{ t('subscription.currentPlan') }} +
+
+
+ +
+ + +
+

{{ t('subscription.noPlan') }}

+
+ + +
+ +
+

+ 🔧 {{ t('subscription.addonsSection') }} +

+

{{ t('subscription.addonsDesc') }}

+
+ + +
+ + 🛒 {{ t('subscription.selectedAddons') }} ({{ selectedAddonIds.size }}): + {{ formatPrice(cartAddonTotal) }}/{{ t('subscription.month') }} +
- + + + +
+
+ +
+ + {{ addon.rules?.display_name || addon.name }} + + + {{ formatPrice(resolvedMonthlyPrice(addon)) }}/{{ t('subscription.month') }} + +
+ + +
+ +

+ {{ addon.rules.marketing.subtitle }} +

+ + +
+
+
+ +{{ addon.rules.allowances.max_vehicles }} +
+
+ 🚗 {{ t('subscription.vehicles') }} +
+
+
+
+ +{{ addon.rules.allowances.max_garages }} +
+
+ 🏠 {{ t('subscription.maxGarages') }} +
+
+
+
+ +{{ addon.rules.allowances.monthly_free_credits }} +
+
+ 💰 {{ t('subscription.freeCredits') }} +
+
+ +
+ {{ t('subscription.noAddonsAvailable') }} +
+
+ +
+ + + +
+
+
+
- + @@ -157,12 +307,15 @@