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