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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,404 @@
# 🏗️ Logic Spec: Subscription Card Upgrade (FinanceMainView)
**Date:** 2026-07-27
**Author:** System Architect (Architect Mode)
**Status:** 🔵 Blueprint — Ready for Code Mode Hand-off
**Masterbook Ref:** Master Book 2.0 / Epic 3: Financial Motor / Subscription Packages
---
## 1. 🎯 Objective
Transform the "📦 Csomagok" card (Card 2) in [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue:118) from a dead display of the raw DB slug (`private_pro_v1`) into a highly functional dashboard widget showing:
| # | Feature | Priority |
|---|---------|----------|
| 1 | Human-readable display name (e.g., "Privát Pro") | P0 |
| 2 | Expiry/renewal date (e.g., "Érvényes: 2026.12.31-ig") | P0 |
| 3 | Vehicle usage capacity (e.g., "Kezelt járművek: 3 / 5") | P1 |
| 4 | Dynamic CTA button ("Csomag kezelése" / "Upgrade") | P1 |
---
## 2. 🔍 Gap Analysis
### 2.1 Current State — What Card 2 Shows Today
[`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue:118-154):
```ts
const subscriptionPlanName = computed(() => {
return authStore.user?.subscription_plan || '—'
})
```
This displays the raw DB slug like `private_pro_v1` with no formatting.
### 2.2 What Data IS Available
| Data Point | Source | Available in authStore? | Available via API? |
|------------|--------|------------------------|-------------------|
| `subscription_plan` (slug) | `identity.users.subscription_plan` | ✅ | `/auth/me` |
| `max_vehicles` | Resolved from `SubscriptionTier.rules.allowances` | ✅ | `/auth/me` (line 259) |
| `max_garages` | Resolved from `SubscriptionTier.rules.allowances` | ✅ | `/auth/me` (line 260) |
| `display_name` | `SubscriptionTier.rules.display_name` JSONB | ❌ | Only via `GET /subscriptions/my` |
| `subscription_expires_at` | `UserSubscription.valid_until` | ❌ NOT in `/auth/me` | `GET /subscriptions/my` |
| `vehicleCount` | `vehicleStore.vehicles.length` | N/A (vehicleStore) | N/A |
| `subscription_valid_until` | Org-level: `OrganizationSubscription.valid_until` | Partially (org data) | `GET /organizations/my` |
### 2.3 Key Gaps to Fill
1. **`subscription_expires_at` not in `/auth/me` response** — The backend `read_users_me()` already resolves `SubscriptionTier` for `max_vehicles`/`max_garages` but does NOT extract `valid_until` from the `UserSubscription`/`OrganizationSubscription` record.
2. **`subscription_display_name` not in `/auth/me` response** — The `SubscriptionTier.rules["display_name"]` JSONB field exists (e.g., `"display_name": "Privát Pro"`) but is never sent to the frontend via `/auth/me`.
3. **Card 2 doesn't use `vehicleStore`** — The vehicle count is already available in the `SubscriptionStatusWidget.vue` but not in `FinanceMainView.vue`.
---
## 3. 📐 Architecture: Data Flow (Current vs. Target)
```mermaid
graph TD
subgraph "Current Flow (BROKEN)"
A1["/auth/me"] -->|subscription_plan slug| B1[authStore.user]
B1 -->|raw slug 'private_pro_v1'| C1[FinanceMainView Card 2]
end
subgraph "Target Flow (UPGRADED)"
A2["/auth/me"] -->|+ display_name + expires_at + max_vehicles| B2[authStore.user]
A2 -->|+ subscription_tier_id| B2
B2 -->|display_name 'Privát Pro'| C2[FinanceMainView Card 2]
B2 -->|expires_at '2026-12-31'| C2
B2 -->|max_vehicles 5| C2
D[vehicleStore.vehicles.length] -->|vehicleCount 3| C2
end
```
---
## 4. 🛠️ Implementation Blueprint
### PHASE 1: Backend — Enrich `/auth/me` Response
**File:** [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py)
#### Step 1.1: Resolve `subscription_expires_at` and `subscription_display_name`
In the `read_users_me()` function, after the P0 block at lines 206-253 (where `max_vehicles` and `max_garages` are resolved), add new resolution logic:
**Location:** After line 253 (`max_garages = int(allowances.get("max_garages", 1))`), add:
```python
# ── P0 Subscription Card Upgrade: resolve display_name and valid_until ──
subscription_display_name = None
subscription_valid_until = None
subscription_tier_id = None
if active_org_id is not None:
# Org-level: get valid_until from OrganizationSubscription
org_sub_full_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
org_sub_full = (await db.execute(org_sub_full_stmt)).scalar_one_or_none()
if org_sub_full:
subscription_valid_until = org_sub_full.valid_until
subscription_tier_id = org_sub_full.tier_id
else:
# Personal mode: get valid_until from UserSubscription
user_sub_full_stmt = (
select(UserSubscription)
.where(
UserSubscription.user_id == current_user.id,
UserSubscription.is_active == True
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
user_sub_full = (await db.execute(user_sub_full_stmt)).scalar_one_or_none()
if user_sub_full:
subscription_valid_until = user_sub_full.valid_until
subscription_tier_id = user_sub_full.tier_id
# Resolve display_name from the already-fetched tier (reuse from lines 211-253)
if tier and tier.rules:
subscription_display_name = tier.rules.get("display_name", None)
```
#### Step 1.2: Add new fields to the response dict
After line 260 (`response_data["max_garages"] = max_garages`), add:
```python
response_data["subscription_expires_at"] = (
subscription_valid_until.isoformat() if subscription_valid_until else None
)
response_data["subscription_display_name"] = subscription_display_name
response_data["subscription_tier_id"] = subscription_tier_id
```
**Note:** The `_build_user_response()` helper at line 121-144 defines the base shape. We do NOT need to modify it — the new fields are injected after `_build_user_response()` returns, just like `max_vehicles` and `max_garages` already are.
---
### PHASE 2: Frontend — Update Type Definitions
**File:** [`frontend_app/src/stores/auth.ts`](frontend_app/src/stores/auth.ts)
#### Step 2.1: Extend `UserProfile` interface
Add two new fields to the `UserProfile` interface (after line 66 `max_garages`):
```typescript
/** P0 Subscription Card Upgrade: Human-readable plan name (e.g., "Privát Pro") */
subscription_display_name?: string | null
/** P0 Subscription Card Upgrade: FK to system.subscription_tiers */
subscription_tier_id?: number | null
```
The `subscription_expires_at` field already exists at line 63. ✅
---
### PHASE 3: Frontend — Rewrite Card 2 in FinanceMainView
**File:** [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue)
#### Step 3.1: Import `useVehicleStore`
Add to the imports at line 228:
```typescript
import { useVehicleStore } from '../stores/vehicle'
```
#### Step 3.2: Initialize vehicle store
After line 234:
```typescript
const vehicleStore = useVehicleStore()
```
#### Step 3.3: Replace the computed property
Replace lines 249-252:
```typescript
// ── Card 2: Subscription data (upgraded) ──────────────────────────
const subscriptionDisplayName = computed(() => {
// Priority: 1) display_name from backend, 2) humanized slug, 3) fallback
const displayName = authStore.user?.subscription_display_name
if (displayName) return displayName
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
// Simple slug→human mapping as fallback
const slugMap: Record<string, string> = {
'private_pro_v1': 'Privát Pro',
'corp_premium_v1': 'Céges Prémium',
'corp_premium_plus_v1': 'Céges Prémium Plus',
}
return slugMap[plan] || plan
})
const subscriptionExpiresAt = computed(() => {
return authStore.user?.subscription_expires_at ?? null
})
const formattedExpiry = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
})
const isExpired = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return false
return new Date(raw) < new Date()
})
const vehicleCount = computed(() => vehicleStore.vehicles?.length || 0)
const maxVehicles = computed(() => authStore.user?.max_vehicles ?? 0)
const vehiclePercent = computed(() => {
if (!maxVehicles.value || maxVehicles.value <= 0) return 0
return Math.min(100, (vehicleCount.value / maxVehicles.value) * 100)
})
// CTA routing depends on mode
const ctaRoute = computed(() => {
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
return `/organization/${authStore.user.active_organization_id}/subscription`
}
return '/dashboard/subscription'
})
const ctaLabel = computed(() => {
if (isExpired.value) return t('subscription.renewNow') || 'Újítás most'
return t('subscription.managePlan')
})
```
#### Step 3.4: Replace Card 2 template (lines 118-154)
Replace the entire Card 2 block with:
```vue
<!--
Card 2: 📦 Csomagok (Packages) UPGRADED
Shows display_name, expiry date, vehicle usage, dynamic CTA.
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
<span
:class="isExpired ? 'bg-red-500/30 text-red-200' : 'bg-emerald-500/30 text-emerald-200'"
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
>
{{ isExpired ? t('subscription.expired') : t('subscription.active') }}
</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Plan name -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
{{ t('subscription.active') }}
</p>
<p class="text-2xl font-extrabold text-sky-600">
{{ subscriptionDisplayName }}
</p>
</div>
<!-- Expiry date -->
<div v-if="formattedExpiry" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
<p class="text-xs text-slate-500">
{{ t('subscription.expiresAt') }}:
<span
:class="isExpired ? 'text-red-600 font-bold' : 'text-slate-700 font-semibold'"
>{{ formattedExpiry }}</span>
</p>
</div>
<!-- Vehicle usage -->
<div v-if="maxVehicles > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs text-slate-500">
<span>🚗 {{ t('subscription.vehicles') }}</span>
<span class="font-semibold text-slate-700">{{ vehicleCount }} / {{ maxVehicles }}</span>
</div>
<div class="w-full h-1.5 rounded-full bg-slate-200 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="vehiclePercent >= 90 ? 'bg-red-500' : vehiclePercent >= 75 ? 'bg-amber-400' : 'bg-sky-500'"
:style="{ width: `${Math.min(vehiclePercent, 100)}%` }"
/>
</div>
</div>
</div>
<!-- CTA Button -->
<div class="shrink-0 pt-2">
<router-link
:to="ctaRoute"
class="block w-full text-center px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 shadow-md hover:shadow-lg active:scale-[0.98]"
:class="isExpired
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white hover:from-amber-400 hover:to-orange-500'
: 'bg-gradient-to-r from-sky-600 to-indigo-600 text-white hover:from-sky-500 hover:to-indigo-500'"
>
{{ ctaLabel }}
</router-link>
</div>
</div>
</div>
```
#### Step 3.5: Add `onMounted` vehicle fetch
In the existing `onMounted` block at line 263-265, add vehicle store fetch:
```typescript
onMounted(() => {
financeStore.fetchWalletBalance()
// Ensure vehicles are loaded for usage display
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
})
```
---
### PHASE 4: Frontend — I18n Keys
**Files:** [`frontend_app/src/i18n/hu.ts`](frontend_app/src/i18n/hu.ts), [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts)
#### Step 4.1: Add new Hungarian keys
In `hu.ts`, inside the `subscription:` block (after line 87):
```typescript
renewNow: 'Újítás most',
upgradePlan: 'Csomag váltása',
```
#### Step 4.2: Add new English keys
In `en.ts`, inside the `subscription:` block (at corresponding position):
```typescript
renewNow: 'Renew Now',
upgradePlan: 'Upgrade Plan',
```
---
## 5. 📋 Files Changed Summary
| # | File | Change Type | Lines Affected |
|---|------|-------------|---------------|
| 1 | [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py) | Add `display_name` + `valid_until` resolution | ~+30 lines after L253 |
| 2 | [`frontend_app/src/stores/auth.ts`](frontend_app/src/stores/auth.ts) | Add `subscription_display_name`, `subscription_tier_id` to `UserProfile` | ~+3 lines after L66 |
| 3 | [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) | Rewrite Card 2 template + computed properties | Replace L118-154, add computed after L252 |
| 4 | [`frontend_app/src/i18n/hu.ts`](frontend_app/src/i18n/hu.ts) | Add `renewNow`, `upgradePlan` keys | ~+2 lines after L87 |
| 5 | [`frontend_app/src/i18n/en.ts`](frontend_app/src/i18n/en.ts) | Add `renewNow`, `upgradePlan` keys | ~+2 lines at matching position |
---
## 6. ⚠️ Risk Assessment
| Risk | Mitigation |
|------|-----------|
| `display_name` may be `null` in old tier records | Fallback chain: display_name → slug→human map → "—" |
| `valid_until` may be `None` for lifetime subscriptions | Conditionally render expiry block (`v-if="formattedExpiry"`) |
| `vehicleStore.vehicles` might not be loaded yet | `onMounted` fetches vehicles if needed |
| Corporate vs. Individual mode routing | `ctaRoute` computed handles both modes |
---
## 7. 🧪 Testing Checklist
- [ ] Card 2 shows `display_name` (e.g., "Privát Pro") instead of raw slug
- [ ] Expiry date displayed as `YYYY.MM.DD` format
- [ ] "Lejárt" badge shown when subscription is expired
- [ ] Vehicle usage bar shows correct `vehicleCount / maxVehicles`
- [ ] CTA button navigates to correct route (personal or org subscription page)
- [ ] CTA label changes to "Újítás most" when expired
- [ ] Fallback works for users without `display_name` in their tier
- [ ] No regressions on Card 1 (Wallet) and other cards
- [ ] I18n keys work in both `hu` and `en` locales
---
## 8. 📎 References
- [`SubscriptionTier` model](backend/app/models/core_logic.py:68) — `system.subscription_tiers`
- [`UserSubscription` model](backend/app/models/core_logic.py:174) — `finance.user_subscriptions`
- [`OrganizationSubscription` model](backend/app/models/core_logic.py:143) — `finance.org_subscriptions`
- [`SubscriptionService`](backend/app/services/subscription_service.py:74) — Full subscription resolution logic
- [`SubscriptionStatusWidget`](frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue) — Existing rich widget (dark theme)
- [`SubscriptionInfoModal`](frontend_app/src/components/subscription/SubscriptionInfoModal.vue) — Modal with detailed info
- [P0 Subscription JSONB Audit](docs/p0_subscription_jsonb_structural_audit_report.md) — Tier JSONB structure verified

View File

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

View File

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