felhasználói felületre pü

This commit is contained in:
Roo
2026-07-27 08:39:18 +00:00
parent c9118bf52f
commit 6f28d3e70d
72 changed files with 6311 additions and 749 deletions

View File

@@ -0,0 +1,181 @@
# 🔍 Comprehensive Financial Module Audit
**Date:** 2026-07-25
**Auditor:** Rendszer-Architect (🏗️ Architect Mode)
**Scope:** Full-stack financial system audit — Models, API, Services, Workers, Frontend
**Executive Summary:**
> The financial backend is **well-architected and modular**. The model layer follows DDD schema separation, the service layer splits into distinct managers, the API provides complete user-facing endpoints, and all system workers properly write to the audit ledger. The **critical gap is the frontend** — zero financial UI exists for end users despite the backend being fully ready.
---
## 1. MODELS — Schema & Architecture
### ✅ STATUS: WELL-ARCHITECTED — No Monolith
| Schema | Table | Purpose | File |
|--------|-------|---------|------|
| `identity` | `wallets` | Quadruple wallet: earned, purchased, service_coins + ActiveVouchers (FIFO) | [`models/identity/identity.py:263`](dev/service_finder/backend/app/models/identity/identity.py:263) |
| `identity` | `active_vouchers` | FIFO voucher tracking with expiration | [`models/identity/identity.py:316`](dev/service_finder/backend/app/models/identity/identity.py:316) |
| `audit` | `financial_ledger` | Immutable double-entry ledger (DEBIT/CREDIT) | [`models/system/audit.py:78`](dev/service_finder/backend/app/models/system/audit.py:78) |
| `fleet_finance` | `cost_categories` | Hierarchical cost category tree | [`models/fleet_finance/models.py:37`](dev/service_finder/backend/app/models/fleet_finance/models.py:37) |
| `fleet_finance` | `asset_costs` | Operational expense log (TCO) | [`models/fleet_finance/models.py:109`](dev/service_finder/backend/app/models/fleet_finance/models.py:109) |
| `fleet_finance` | `asset_financials` | Acquisition & depreciation | [`models/fleet_finance/models.py:180`](dev/service_finder/backend/app/models/fleet_finance/models.py:180) |
| `fleet_finance` | `insurance_providers` | Insurance company catalog | [`models/fleet_finance/models.py:219`](dev/service_finder/backend/app/models/fleet_finance/models.py:219) |
| `fleet_finance` | `vehicle_insurance_policies` | Vehicle insurance policies | [`models/fleet_finance/models.py:248`](dev/service_finder/backend/app/models/fleet_finance/models.py:248) |
| `fleet_finance` | `vehicle_tax_obligations` | Tax obligations per vehicle | [`models/fleet_finance/models.py:298`](dev/service_finder/backend/app/models/fleet_finance/models.py:298) |
| `finance` | `issuers` | Billing entities (KFT/EV/BT/ZRT) | [`models/marketplace/finance.py:27`](dev/service_finder/backend/app/models/marketplace/finance.py:27) |
| `marketplace` | `payment_intents` | Payment intent lifecycle (Double Lock) | [`models/marketplace/payment.py`](dev/service_finder/backend/app/models/marketplace/payment.py) |
| `core_logic` | `subscription_tiers` | Subscription tier definitions | [`models/core_logic.py`](dev/service_finder/backend/app/models/core_logic.py) |
| `core_logic` | `user_subscriptions` / `org_subscriptions` | Subscription state tracking | [`models/core_logic.py`](dev/service_finder/backend/app/models/core_logic.py) |
### Key Enums (Defined in [`audit.py:58`](dev/service_finder/backend/app/models/system/audit.py:58))
- `LedgerEntryType`: `DEBIT`, `CREDIT`
- `WalletType`: `EARNED`, `PURCHASED`, `SERVICE_COINS`, `VOUCHER`
- `LedgerStatus`: `PENDING`, `SUCCESS`, `FAILED`, `REFUNDED`, `REFUND`
---
## 2. API ENDPOINTS — Routing & User Access
### ✅ STATUS: WELL-COVERED
| Method | Endpoint | Purpose | User-Facing? | RBAC |
|--------|----------|---------|-------------|------|
| `POST` | `/billing/upgrade` | Package upgrade | ✅ Yes | Auth required |
| `POST` | `/billing/payment-intent/create` | Create PaymentIntent | ✅ Yes | Auth required |
| `POST` | `/billing/payment-intent/{id}/stripe-checkout` | Initiate Stripe checkout | ✅ Yes | Auth required |
| `POST` | `/billing/payment-intent/{id}/process-internal` | Internal gifting deduction | ✅ Yes | Auth required |
| `POST` | `/billing/stripe-webhook` | Stripe callback | N/A (webhook) | Signature |
| `GET` | `/billing/payment-intent/{id}/status` | PaymentIntent status | ✅ Yes | Auth required |
| `GET` | `/billing/wallet/balance` | User wallet balances | ✅ Yes | Auth required |
| `GET` | `/billing/wallet/transactions` | User transaction History | ✅ Yes | Auth required |
| `POST` | `/financial-manager/purchase-package` | Full purchase lifecycle | ✅ Yes | Auth required |
| `GET` | `/financial-manager/status` | Service health check | ✅ Yes | Auth required |
| `GET` | `/finance/issuers/` | List billing issuers | ❌ Admin | `finance:view` |
| `PATCH` | `/finance/issuers/{id}` | Update issuer | ❌ Admin | `finance:edit` |
| `GET` | `/admin/finance/ledger` | Admin ledger view | ❌ Admin | `finance:view` |
| `POST` | `/expenses/` | Create expense (TCO) | ✅ Yes | Auth + Capability |
| `PUT` | `/expenses/{id}` | Update expense | ✅ Yes | Auth |
| `GET` | `/expenses/` | List all expenses | ❌ Admin | Auth |
| `GET` | `/expenses/{asset_id}` | Asset-specific expenses | ✅ Yes | Auth |
| `GET` | `/subscriptions/public` | Public package catalog | ✅ Public | None |
| `GET` | `/subscriptions/my` | Current subscription | ✅ Yes | Auth |
| `GET` | `/subscriptions/feature-flags` | Resolved feature flags | ✅ Yes | Auth |
### Router Registration (in [`api/v1/api.py:1`](dev/service_finder/backend/app/api/v1/api.py:1))
- `billing.router``/billing`
- `financial_manager.router``/financial-manager`
- `finance_admin.router``/finance/issuers` + `/admin/finance`
---
## 3. SERVICES — Monolith vs. Modular Managers
### ✅ STATUS: MODULAR & SEPARATED — Minor Overlap Risk
| Service File | Responsibility | Lines |
|-------------|---------------|-------|
| [`billing_engine.py`](dev/service_finder/backend/app/services/billing_engine.py:1) | Core: PricingCalculator, SmartDeduction (FIFO), AtomicTransactionManager (double-entry) | ~1070 |
| [`financial_manager.py`](dev/service_finder/backend/app/services/financial_manager.py:1) | Orchestrator: full purchase lifecycle (validate→price→intent→pay→activate→commission) | ~520 |
| [`financial_interfaces.py`](dev/service_finder/backend/app/services/financial_interfaces.py:1) | ABCs: `BasePaymentGateway`, `BaseInvoicingService` + exceptions | ~186 |
| [`financial_orchestrator.py`](dev/service_finder/backend/app/services/financial_orchestrator.py:1) | Unit of Work: payment processing with issuer selection (vetésforgó) | ~449 |
| [`payment_router.py`](dev/service_finder/backend/app/services/payment_router.py:1) | Router: PaymentIntent creation, Stripe Double Lock, internal gifting | ~495 |
| [`cost_service.py`](dev/service_finder/backend/app/services/cost_service.py:1) | Fleet operational cost tracking with telemetry | ~140 |
| [`subscription_activator.py`](dev/service_finder/backend/app/services/subscription_activator.py:1) | Subscription activation (user/org with stacking) | ~unknown |
| [`commission_service.py`](dev/service_finder/backend/app/services/commission_service.py:1) | MLM commission distribution | ~unknown |
| [`stripe_adapter.py`](dev/service_finder/backend/app/services/stripe_adapter.py:1) | Stripe API adapter | ~unknown |
| [`mock_payment_gateway.py`](dev/service_finder/backend/app/services/mock_payment_gateway.py:1) | Test gateway (auto-approve mode) | ~unknown |
### Architecture Decision: Strategy Pattern
The [`financial_interfaces.py`](dev/service_finder/backend/app/services/financial_interfaces.py:1) defines [`BasePaymentGateway`](dev/service_finder/backend/app/services/financial_interfaces.py:13) and [`BaseInvoicingService`](dev/service_finder/backend/app/services/financial_interfaces.py:91) ABCs. `FinancialManager` accepts any gateway implementation via constructor injection.
### ⚠️ Concern: Overlap Between `financial_orchestrator` and `billing_engine`
- Both create `FinancialLedger` entries
- `financial_orchestrator.process_payment()` writes ledger entries with raw `update()` SQL instead of using `AtomicTransactionManager`
- `financial_orchestrator` accesses `Wallet` fields directly via raw SQL updates
- This is a **code duplication risk** — two different ledger-writing paths exist
### Recommendation
- Either retire `FinancialOrchestrator.process_payment()` in favor of `AtomicTransactionManager` + `PaymentRouter`, or refactor `FinancialOrchestrator` to delegate to `BillingEngine` classes.
---
## 4. WORKERS — Ledger Integration
### ✅ STATUS: FULLY CONNECTED — All workers write to audit ledger
| Worker | Ledger Entry? | Transaction Type | Amount | WalletType |
|--------|--------------|-----------------|--------|------------|
| [`inactivity_monitor_worker.py`](dev/service_finder/backend/app/workers/system/inactivity_monitor_worker.py:1) | ✅ | `ACCOUNT_SUSPENDED_INACTIVITY` | 0.0 | `EARNED` |
| [`subscription_worker.py`](dev/service_finder/backend/app/workers/system/subscription_worker.py:1) | ✅ | `SUBSCRIPTION_EXPIRED` | 0.0 | `SYSTEM` |
| [`subscription_monitor_worker.py`](dev/service_finder/backend/app/workers/system/subscription_monitor_worker.py:1) | ✅ | `SUBSCRIPTION_EXPIRED` | 0.0 | `EARNED` |
### ⚠️ Minor Inconsistency
- `subscription_worker.py` line 75 uses `WalletType.SYSTEM` — this value does NOT exist in the [`WalletType` enum](dev/service_finder/backend/app/models/system/audit.py:63) (which only has `EARNED`, `PURCHASED`, `SERVICE_COINS`, `VOUCHER`). This would raise an `AttributeError` at runtime.
- `subscription_monitor_worker.py` correctly uses `WalletType.EARNED`.
---
## 5. FRONTEND — Wallet & Transaction Views
### ❌ STATUS: COMPLETELY MISSING
A search across the entire `frontend/src/` directory for Vue files containing `wallet`, `transaction`, `balance`, `purchase`, `subscription`, or `billing` returned **zero results**.
The backend already provides:
- `GET /billing/wallet/balance` → Returns quadruple wallet balances
- `GET /billing/wallet/transactions` → Returns paginated transaction history with filtering
- `GET /subscriptions/my` → Returns current subscription details
- `GET /subscriptions/feature-flags` → Returns feature flags
But **no frontend component calls any of these endpoints**.
### What Needs Building
1. **WalletBalanceCard.vue** — Display earned/purchased/service_coins/voucher balances
2. **TransactionHistory.vue** — Paginated transaction list with type/wallet filters
3. **SubscriptionStatusBanner.vue** — Current tier, expiry, upgrade CTA
4. **CheckoutView.vue** — Package selection → PaymentIntent → Stripe/internal redirect
5. **PaymentStatusView.vue** — Success/cancel pages after Stripe redirect
6. **Admin Ledger View** (already spec'd in [`docs/sf/epic_10_admin_frontend_spec.md`](dev/service_finder/docs/sf/epic_10_admin_frontend_spec.md)) — Paginated financial ledger with user join data
---
## 6. GAP ANALYSIS SUMMARY
| Area | Status | Criticality | Action |
|------|--------|-------------|--------|
| **Backend Models** | ✅ Complete | — | No action needed |
| **Backend API — User-facing** | ✅ Complete | — | No action needed |
| **Backend API — Admin** | ✅ Complete | — | No action needed |
| **Backend Services** | ✅ Mostly modular | ⚠️ Medium | Refactor `FinancialOrchestrator` to delegate ledger writes to `AtomicTransactionManager` |
| **Workers → Ledger** | ✅ Connected | 🔴 Bug | Fix `subscription_worker.py:75``WalletType.SYSTEM` does not exist |
| **Frontend — Wallet** | ❌ Missing | 🔴 Critical | Build `WalletBalanceCard.vue` + `TransactionHistory.vue` |
| **Frontend — Checkout** | ❌ Missing | 🔴 Critical | Build subscription purchase flow (catalog → intent → payment → confirmation) |
| **Frontend — Admin Ledger** | ❌ Missing | 🟡 Medium | Build admin ledger view (already spec'd) |
---
## 7. RECOMMENDED ACTIONS (Priority Order)
### 🔴 P0 — Critical Bugs
1. **Fix `subscription_worker.py:75`**: Change `WalletType.SYSTEM` to `WalletType.EARNED` (or add `SYSTEM` to the enum)
2. **Fix `FinancialOrchestrator.refund_payment()` line 399/403**: Code references `Wallet.wallet_type` field which doesn't exist on the Wallet model; also references `wallet.balance` which doesn't exist. This code would **crash at runtime**.
### 🔴 P0 — Missing Frontend
3. Build `WalletBalanceCard.vue` — user wallet summary display
4. Build `TransactionHistory.vue` — paginated transaction list
5. Build subscription checkout flow (catalog → Intent → payment → confirmation)
### 🟡 P1 — Code Quality
6. Refactor `FinancialOrchestrator.process_payment()` to delegate to `AtomicTransactionManager` for ledger creation (eliminate dual ledger-writing paths)
7. Audit `FinancialOrchestrator.refund_payment()` — it references non-existent fields (`Wallet.wallet_type`, `Wallet.balance`)
### 🟢 P2 — Documentation
8. Update [`epic_3_financial_motor_architecture.md`](dev/service_finder/docs/sf/epic_3_financial_motor_architecture.md:1) with current service file structure
9. Create `logic_spec_financial_manager.md` documenting the full purchase lifecycle flow with Mermaid sequence diagram
---
*Audit completed by Rendszer-Architect. Ready for Gitea ticket creation.*

View File

@@ -0,0 +1,203 @@
# P0 Root Cause Analysis: POST /api/v1/expenses/ 500 Internal Server Error
**Date:** 2026-07-26
**Gitea Issue:** [#421](https://gitea.kincses.dev/kincses/service-finder/issues/421)
**Severity:** P0 (Critical — breaks entire expense creation flow)
**Status:** Architect Analysis Complete — Awaiting Code Fix
---
## 📋 Executive Summary
The `POST /api/v1/expenses/` endpoint crashes with a `500 Internal Server Error` because the helper function `_check_org_capability()` contains a **broken import path** and a **wrong join column name**. Both defects are located in `backend/app/api/v1/endpoints/expenses.py`.
---
## 🔍 Root Cause Trace
### Traceback (from `docker compose logs sf_api`)
```
File "/app/app/api/v1/endpoints/expenses.py", line 377, in create_expense
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
File "/app/app/api/v1/endpoints/expenses.py", line 104, in _check_org_capability
from app.models.fleet.org_role import OrgRole
ModuleNotFoundError: No module named 'app.models.fleet.org_role'
```
### Call Flow
```mermaid
flowchart TD
A["POST /api/v1/expenses/"] --> B["expenses.py: create_expense() line 377"]
B --> C["_check_org_capability() line 93"]
C --> D["line 104: from app.models.fleet.org_role import OrgRole"]
D --> E["ModuleNotFoundError 💥"]
E --> F["500 Internal Server Error"]
```
---
## 🐛 Bug #1: Wrong Import Path (line 104)
### Broken Code
```python
# File: backend/app/api/v1/endpoints/expenses.py, line 104
from app.models.fleet.org_role import OrgRole
```
### Why It Fails
There is **no directory** `backend/app/models/fleet/` and no file `org_role.py` at that path.
### Correct Import
The `OrgRole` model lives in `backend/app/models/marketplace/organization.py`:
```python
from app.models.marketplace.organization import OrgRole
```
### Evidence: Other Files Import Correctly
| File | Import | Status |
|------|--------|--------|
| `backend/app/api/deps.py:13` | `from app.models.marketplace.organization import OrgRole, OrganizationMember` | ✅ Correct |
| `backend/app/api/v1/endpoints/assets.py:14` | `from app.models import ... OrgRole` (via `__init__.py`) | ✅ Correct |
| `backend/app/api/v1/endpoints/organizations.py:23` | `from app.models.marketplace.organization import ... OrgRole` | ✅ Correct |
| `backend/app/api/v1/endpoints/expenses.py:104` | `from app.models.fleet.org_role import OrgRole` | ❌ **BROKEN** |
---
## 🐛 Bug #2: Wrong Join Column (line 108)
### Broken Code
```python
# File: backend/app/api/v1/endpoints/expenses.py, lines 106-114
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name) # ← BUG
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
```
### Why It Fails
The `OrgRole` model's column is `name_key` (line 55 of the model), **not** `name`:
```python
class OrgRole(Base):
__tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"}
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
# ... no 'name' column exists
```
Attempting `OrgRole.name` would raise an `AttributeError` after the import is fixed, causing a second crash.
### Correct Join
```python
.join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
```
### Evidence: `assets.py` Has the Correct Pattern
The `assets.py` endpoint uses the identical capability-check pattern correctly:
```python
# assets.py line 80-83 — CORRECT pattern
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name, # ← Uses name_key, not .name
OrgRole.is_active == True
).limit(1)
```
---
## 🧩 Impact Analysis
### What's Broken
- **Entire `POST /api/v1/expenses/` endpoint** — all expense creation requests fail with 500
- The crash occurs at the **RBAC Phase 2 capability check** (JSONB-based `can_approve_expense`)
- This blocks the P0 Smart Expense Workflow (odometer normalization, VAT calculation, gamification hooks, AssetEvent linking)
### What Still Works
- `GET /api/v1/expenses/` — list all expenses (does not call `_check_org_capability`)
- `GET /api/v1/expenses/{asset_id}` — list asset expenses (does not call `_check_org_capability`)
- `PUT /api/v1/expenses/{expense_id}` — update expense (does not call `_check_org_capability`)
### Why This Was Not Caught
1. The function uses a lazy import inside the function body — the import error only surfaces at runtime, not at module load time.
2. No e2e test exercises the `POST /expenses/` path through this function.
3. The `backend/app/tests/e2e/test_expense_flow.py` test targets `POST /api/v1/expenses/add`, which is a **different, legacy endpoint** — not the current `POST /api/v1/expenses/`.
---
## 🔧 Fix Plan (2 Lines, 1 File)
### File: `backend/app/api/v1/endpoints/expenses.py`
**Change 1 — Line 104:** Replace the broken import:
```diff
- from app.models.fleet.org_role import OrgRole
+ from app.models.marketplace.organization import OrgRole
```
**Change 2 — Line 108:** Fix the join column:
```diff
- .join(OrganizationMember, OrganizationMember.role == OrgRole.name)
+ .join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
```
### Optional: Hoist to top-level imports
Since `OrganizationMember` is already imported at the top of the file (line 46), move `OrgRole` there too:
```python
# line 46 area — add OrgRole
from app.models.marketplace.organization import OrganizationMember, OrgRole
```
Then remove the lazy import body from `_check_org_capability()`.
---
## 🧪 Verification
1. **Unit test:** `POST /api/v1/expenses/` with valid payload → 201 Created
2. **Capability check:** User with `can_approve_expense: true` → expense `APPROVED`
3. **Capability check:** User without `can_approve_expense``PENDING_APPROVAL`
4. **No ModuleNotFoundError** at any point
---
## 📊 Summary
| Item | Detail |
|------|--------|
| **Endpoint** | `POST /api/v1/expenses/` |
| **Error** | `ModuleNotFoundError: No module named 'app.models.fleet.org_role'` |
| **Crash File** | `backend/app/api/v1/endpoints/expenses.py` |
| **Crash Line** | 104 |
| **Root Cause 1** | Wrong import: `app.models.fleet.org_role``app.models.marketplace.organization` |
| **Root Cause 2** | Wrong column: `OrgRole.name``OrgRole.name_key` |
| **Gitea Issue** | #421 |
| **Fix Complexity** | Trivial (2 lines) |
| **Risk** | Minimal |
---
*Analysis by: Service Finder Rendszer-Architect — 2026-07-26*

View File

@@ -0,0 +1,248 @@
# P0 Root Cause Analysis: Expense Edit Modal Data Binding Failure
**Date:** 2026-07-26
**Scope:** Frontend `CostEntryWizard.vue` + Backend `expenses.py`
**Symptom:** Edit modal defaults category to "Finanszírozás", fails to bind vehicle/provider, missing specific details (fuel amount).
---
## 1. Data Storage: How Expenses & Specific Details Are Persisted
### DB Table: `fleet_finance.asset_costs`
| Column | Type | Stores |
|--------|------|--------|
| `category_id` | `int4` | FK → `fleet_finance.cost_categories.id`**leaf/subcategory ID**, not parent |
| `service_provider_id` | `int4` | FK → `marketplace.service_providers.id` — the selected provider |
| `vendor_organization_id` | `int4` | FK → `fleet.organizations.id` — B2B vendor org |
| `external_vendor_name` | `varchar(200)` | Free-text vendor name |
| `data` | `JSONB` | **All specific details:** `liters`, `net_amount`, `vat_rate`, `invoice_number`, `invoice_date`, `payment_method`, `payment_deadline`, `mileage_at_cost`, `vendor_id`, `vendor_name`, `vendor_source` |
| `amount_gross` | `numeric(18,2)` | Gross (Bruttó) amount |
| `amount_net` | `numeric(18,2)` | Net amount |
| `invoice_number` | `varchar(100)` | Dedicated column (duplicated in `data`) |
**Key Finding:** Specific details like fuel liters, payment method, invoice date, etc. are stored in the `data` JSONB column. There is **no separate related table** for fuel-specific details.
---
## 2. Backend API Analysis
### 2A. No Single-Expense Read Endpoint Exists
The backend has:
- `GET /expenses/` — lists all expenses (paginated)
- `GET /expenses/{asset_id}` — lists expenses for a specific asset
- `PUT /expenses/{expense_id}` — updates an expense
**There is NO `GET /expenses/by-id/{expense_id}` endpoint** that returns a single enriched expense with all resolved relationships.
### 2B. List Endpoints Return Raw ORM Objects
Both list endpoints return `result.scalars().all()` — raw SQLAlchemy `AssetCost` objects serialized by FastAPI's default JSON encoder. The `AssetCostResponse` Pydantic schema (which includes `category_name`, `category_code`, `vendor_name`, `description`, `mileage_at_cost`) is **defined but never used** by the list endpoints.
This means:
- `data` (JSONB) is serialized as a raw dict — this part actually works
- But `category_name`, `category_code`, `vendor_name` are **not resolved** (they require JOINs or relationship loading)
- The response lacks the parent category ID (only the leaf `category_id` is present)
### 2C. PUT Endpoint Has Partial Data Loss Risk
The `update_expense` function handles `mileage_at_cost` and `description` inside `data` JSONB correctly, but the frontend's `updateExpense()` in `cost.ts` sends `service_provider_id` only if it's in the payload. The `PUT` endpoint's `AssetCostUpdate` schema does include `service_provider_id: Optional[int]`, so if the frontend sends it, it would work.
---
## 3. Frontend Data Flow: The Edit Path
### Step 1: User clicks Edit → `CostsView.vue` line 527
```typescript
function openEditWizard(cost: any) {
const vehicleId = cost.asset_id || cost.vehicle_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = cost // ← passes raw list-response object directly
showEditWizard.value = true
}
```
The `cost` object comes from the `GET /expenses` list response — a raw SQLAlchemy object with fields: `id`, `asset_id`, `organization_id`, `category_id`, `amount_net`, `amount_gross`, `vat_rate`, `currency`, `date`, `invoice_number`, `status`, `data`, `service_provider_id`, `vendor_organization_id`, `external_vendor_name`, `invoice_date`, `fulfillment_date`.
### Step 2: Hydration watcher → `CostEntryWizard.vue` lines 972-988
```typescript
watch(() => props.editCost, (cost) => {
if (!cost) return
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
form.mainCategory = String(cost.category_id || '') // ← BUG #1
form.subCategory = String(cost.sub_category_id || '') // ← BUG #1 (never exists)
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
form.vatRate = cost.data?.vat_rate || 27
form.grossAmount = cost.amount_gross || 0
form.currency = cost.currency || 'HUF'
form.invoiceNumber = cost.data?.invoice_number || ''
form.invoiceDate = cost.data?.invoice_date || ''
form.paymentDeadline = cost.data?.payment_deadline || ''
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
form.liters = cost.data?.liters || null // ← BUG #2 area
// NOTE: selectedProvider is NEVER set from cost.service_provider_id! ← BUG #3
// NOTE: selectedVehicleId is NEVER set from cost.asset_id! ← BUG #4
})
```
---
## 4. Root Cause Map: 5 Specific Bugs
### 🔴 BUG #1 — Category Hierarchy Collapse (CRITICAL)
**Location:** `CostEntryWizard.vue:976`
**What happens:**
- `category_id` in the DB is the **leaf/subcategory** ID (e.g., `id=4` for "Üzemanyag / Benzin")
- The wizard has a **two-level** selector: `mainCategory` (parent) → `subCategory` (leaf)
- The hydration sets `form.mainCategory = String(cost.category_id || '')` — puts the leaf ID into the parent dropdown
- `form.subCategory = String(cost.sub_category_id || '')``sub_category_id` doesn't exist in the response, so it's always `''`
- When the component renders, `mainCategory` is set to an ID that doesn't match any main category (since it's a leaf), so the `<select>` defaults to the **first option**: "Finanszírozás"
- Subcategories are never populated because `onMainCategoryChange()` was never triggered with the correct parent ID
**User experience:** Category dropdown shows "Finanszírozás" (first item) instead of the actual category.
**Fix approach:**
1. Backend must return `parent_category_id` alongside `category_id` in the response, OR
2. Frontend must look up the parent from the flattened category tree (`window.__allCostCategories`) and split `category_id` into main+sub correctly
### 🔴 BUG #2 — Vehicle Not Bound When Not in Store (HIGH)
**Location:** `CostsView.vue:530` + `CostEntryWizard.vue:93`
**What happens:**
- `openEditWizard` finds the vehicle by `cost.asset_id` in `vehicleStore.vehicles`
- If the vehicle is not in the store (e.g., filtered by another org, or not loaded), `editVehicle` is `null`
- In `CostEntryWizard.vue:77`, when `editVehicle` is null AND `userVehicles.length > 0`, the global vehicle dropdown is shown
- But `selectedVehicleId` is **never set** from `cost.asset_id` in the hydration watcher
- Result: vehicle dropdown shows "Select Vehicle..." with nothing selected
**Fix approach:** In the hydration watcher, set `selectedVehicleId.value = cost.asset_id` when `props.vehicle` is null.
### 🔴 BUG #3 — Provider/ServiceProvider Not Hydrated (HIGH)
**Location:** `CostEntryWizard.vue:972-988` (absence of provider hydration)
**What happens:**
- The DB stores `service_provider_id` (int) and `external_vendor_name` (string) directly on the AssetCost row
- The list response includes these fields
- But the hydration watcher **never reads** `cost.service_provider_id` or `cost.external_vendor_name`
- `selectedProvider` ref is never populated, so `ProviderAutocomplete` shows empty
- The user sees no provider selected even though one was saved
**Fix approach:** In the hydration watcher, if `cost.service_provider_id` exists, construct a `ProviderSearchResult` object and set `selectedProvider.value`. If only `external_vendor_name` exists, set it as the provider name.
### 🟡 BUG #4 — `data.liters` May Be Lost in List Serialization (MEDIUM)
**Location:** `CostEntryWizard.vue:987`
**What happens:**
- The hydration reads `cost.data?.liters`
- The list endpoint returns raw SQLAlchemy objects; the `data` JSONB column **should** serialize correctly as a dict via FastAPI's `jsonable_encoder`
- However, if the `data` column is `None` or `{}` in the DB, `cost.data?.liters` returns `undefined`, and `|| null` gives `null`
- This should work for most cases, but there's a subtle issue: the `data` field on the SQLAlchemy model has `server_default="'{}'::jsonb"`, but if a row was inserted without going through SQLAlchemy (e.g., raw SQL), `data` could be `None`
**Fix approach:** Add defensive null-check: `(cost.data && cost.data.liters) ? cost.data.liters : null`
### 🟡 BUG #5 — `mainCategory` Watcher Clears `subCategory` on Hydration (MEDIUM)
**Location:** `CostEntryWizard.vue:818-819`
**What happens:**
- The hydration watcher runs and sets `form.mainCategory = String(cost.category_id || '')`
- Vue's reactivity triggers the `v-model` change on the `<select>` element
- This calls `@change="onMainCategoryChange"` which does:
```typescript
form.subCategory = '' // ← CLEARS whatever the hydration may try to set
subCategories.value = []
```
- Even if we fix BUG #1 to correctly populate subCategory, the `@change` handler would clear it
**Fix approach:** In the hydration watcher, after setting `mainCategory`, manually call `onMainCategoryChange()` (which populates `subCategories`), and THEN set `form.subCategory` to the correct leaf ID. The order must be: set mainCategory → populate subCategories → set subCategory.
---
## 5. Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ GET /expenses?organization_id=X │
│ └→ returns: [AssetCost ORM objects] │
│ fields: id, asset_id, category_id(leaf!), service_provider_id, │
│ amount_gross, date, data{liters, net_amount, ...}, │
│ external_vendor_name, invoice_date, ... │
│ │
│ MISSING: parent_category_id, category_name, vendor_name, │
│ vehicle_name, license_plate │
│ │
│ There is NO GET /expenses/by-id/{id} endpoint! │
└──────────────────────┬──────────────────────────────────────────────┘
│ raw ORM objects (FastAPI jsonable_encoder)
┌─────────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ CostsView.vue │
│ └→ openEditWizard(cost) │
│ editCostData.value = cost ← raw list item │
│ editVehicle.value = store.find(v => v.id === cost.asset_id) │
│ │
│ CostEntryWizard.vue │
│ └→ watch(editCost): │
│ form.mainCategory = String(cost.category_id) ← BUG: leaf ID │
│ form.subCategory = String(cost.sub_category_id) ← BUG: undef │
│ form.liters = cost.data?.liters │
│ selectedProvider = ??? ← BUG: never │
│ selectedVehicleId = ??? ← BUG: never │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 6. Recommended Fix Plan
### Phase 1: Backend (Minimal — No Schema Changes)
1. **Create `GET /expenses/by-id/{expense_id}` endpoint** in `expenses.py` that:
- Fetches a single `AssetCost` by ID
- JOINs `CostCategory` to resolve `category_name`, `category_code`, `parent_id`
- JOINs `ServiceProvider` to resolve provider name
- Returns the data using `AssetCostResponse` schema (already defined!)
- Includes `parent_category_id` in the response for the two-level dropdown
2. **Optionally, enrich the list endpoint** to return `parent_category_id` by joining `CostCategory.parent_id`, but this is less critical if the single-expense endpoint is used for edit hydration.
### Phase 2: Frontend (Edit Path)
1. **In `CostsView.vue` `openEditWizard()`:** fetch the full expense detail from the new `GET /expenses/by-id/{id}` endpoint instead of passing the raw list object.
2. **In `CostEntryWizard.vue` hydration watcher:**
- Split `category_id` into `mainCategory` (parent) and `subCategory` (leaf) using `window.__allCostCategories`
- Call `onMainCategoryChange()` after setting `mainCategory` to populate subcategories, then set `subCategory`
- Set `selectedProvider.value` from `cost.service_provider_id` + resolved provider name
- Set `selectedVehicleId.value` from `cost.asset_id` when vehicle prop is null
- Defensive null-check on `cost.data?.liters`
### Phase 3: Edge Cases
1. When `service_provider_id` is null but `external_vendor_name` is set, populate the provider autocomplete with the free-text name
2. Handle the case where `vendor_organization_id` points to a B2B org (different from `service_provider_id`)
---
## 7. Files That Need Changes
| File | Change Type | Priority |
|------|-------------|----------|
| `backend/app/api/v1/endpoints/expenses.py` | Add `GET /expenses/by-id/{id}` endpoint | P0 |
| `backend/app/schemas/asset_cost.py` | Add `parent_category_id` to `AssetCostResponse` | P0 |
| `frontend_app/src/views/costs/CostsView.vue` | Fetch full expense detail on edit | P0 |
| `frontend_app/src/components/cost/CostEntryWizard.vue` | Fix category split, provider hydration, vehicle selection | P0 |
| `frontend_app/src/stores/cost.ts` | Add `fetchExpenseById()` action (optional, if store-based) | P1 |

View File

@@ -0,0 +1,149 @@
# P0 Bug #420: `GET /api/v1/billing/wallet/balance` - 500 Internal Server Error
**Dátum:** 2026-07-26
**Súlyosság:** P0 - Blokkoló (minden wallet/balance hívást érint)
**Státusz:** Megoldva (pycache purge + restart)
---
## 1. Hiba Leírása
A `GET /api/v1/billing/wallet/balance` végpont `500 Internal Server Error` hibával tért vissza bizonyos meglévő felhasználók (pl. user_id=86, 28) esetén.
### Hibaüzenet a logból:
```
2026-07-26 12:37:49,388 [ERROR] app.api.v1.endpoints.billing: Pénztárca egyenleg lekérdezési hiba: 'FinancialLedger' object has no attribute 'description'
```
### Érintett fájlok:
- [`backend/app/models/system/audit.py:78`](backend/app/models/system/audit.py:78) — `FinancialLedger` SQLAlchemy modell
- [`backend/app/services/billing_engine.py:1078`](backend/app/services/billing_engine.py:1078) — `get_user_balance()`
- [`backend/app/services/billing_engine.py:598`](backend/app/services/billing_engine.py:598) — `get_wallet_summary()`
- [`backend/app/api/v1/endpoints/billing.py:298`](backend/app/api/v1/endpoints/billing.py:298) — `get_wallet_balance()` endpoint
---
## 2. Root Cause Analysis
### 2.1 A FinancialLedger modell
A [`FinancialLedger`](backend/app/models/system/audit.py:78) modell **NEM rendelkezik `description` oszloppal**. Az egyetlen "leíró" mező a `details` (JSONB). A modell oszlopai:
```
id, user_id, person_id, amount, currency, transaction_type,
related_agent_id, details, created_at, entry_type, balance_after,
wallet_type, issuer_id, invoice_status, tax_amount, gross_amount,
net_amount, transaction_id, status
```
### 2.2 A jelenlegi forráskód helyes
A [`billing_engine.py`](backend/app/services/billing_engine.py:542) `get_transaction_history()` metódusa helyesen használja a `details` JSONB mezőt a leírás kinyerésére:
```python
# Line 577-579
desc = None
if entry.details and isinstance(entry.details, dict):
desc = entry.details.get("description") or entry.details.get("desc") or None
```
A [`billing.py`](backend/app/api/v1/endpoints/billing.py:373) router szintén helyesen:
```python
# Line 376-378
description = ""
if entry.details and isinstance(entry.details, dict):
description = entry.details.get("description", "")
```
### 2.3 A tényleges ok: Stale Python Bytecode Cache
Az MD5 ellenőrzés igazolta, hogy a konténerben futó forráskód és a host-on lévő forráskód **azonos**:
| Fájl | MD5 |
|------|-----|
| `billing.py` (host & container) | `6b00968c129376e3db630e266835261b` |
| `billing_engine.py` (host & container) | `54d86868aa077bebfeddaf32c69642fc` |
A konténerben azonban régi `.pyc` bytecode fájlok voltak cache-elve:
```
/app/app/api/v1/endpoints/__pycache__/billing.cpython-312.pyc
/app/app/services/__pycache__/billing_engine.cpython-312.pyc
```
Ezek a `.pyc` fájlok egy **korábbi forráskód-verzióhoz** tartoztak, ahol még létezett `entry.description` hivatkozás a `FinancialLedger` objektumokon (vagy egy másik séma érvényben volt).
### 2.4 Miért nem frissültek a .pyc fájlok?
A `docker-compose.yml`-ben a `sf_api` konténer forráskódja volume mount-tal van csatolva (nem COPY). Amikor a forráskód változik a host-on, a Python értelmező a konténerben azonnal látja az új `.py` fájlokat, de a már legenerált `.pyc` cache fájlok megmaradnak. Ha a `.py` fájl timestamp-je régebbi, mint a `.pyc`-é (vagy a Python nem érzékeli a változást), a régi bytecode fut.
---
## 3. Megoldás
### Végrehajtott lépések:
1. **Pycache törlése a konténerben:**
```bash
docker compose exec sf_api find /app -type d -name "__pycache__" -exec rm -rf {} +
```
2. **Konténer újraindítása:**
```bash
docker compose restart sf_api
```
3. **Verifikáció:** A restart után az endpoint már NEM dob 500-as hibát. A logokban nem jelenik meg több `description` hiba.
---
## 4. Code Mode Blueprint (Preventív Intézkedés)
Bár a jelenlegi forráskód helyes, az alábbi preventív intézkedést javaslom a `Code Mode`-ban:
### 4.1 Docker Compose: pycache purge a startup előtt
A `docker-compose.yml` `sf_api` szolgáltatás `command` sorában futtassunk egy automatikus pycache purge-öt a Uvicorn indítása előtt:
```yaml
command: >
/bin/sh -c "
find /app -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null;
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
"
```
### 4.2 Opcionális: `.pyc` fájlok teljes letiltása fejlesztői környezetben
A `docker-compose.yml` environment változói közé:
```yaml
environment:
- PYTHONDONTWRITEBYTECODE=1
```
Ez megakadályozza, hogy a Python egyáltalán `.pyc` fájlokat generáljon, így mindig a forráskódból fog futni.
---
## 5. Függelék: Mermaid Folyamatábra
```mermaid
sequenceDiagram
participant Client
participant FastAPI as billing.py router
participant BillingEngine as billing_engine.py
participant Model as FinancialLedger
participant DB as PostgreSQL
Client->>FastAPI: GET /api/v1/billing/wallet/balance
FastAPI->>BillingEngine: get_user_balance(db, user_id)
BillingEngine->>BillingEngine: get_wallet_summary(db, user_id)
BillingEngine->>DB: SELECT * FROM identity.wallets WHERE user_id=?
BillingEngine->>DB: SELECT * FROM audit.financial_ledger LIMIT 10
BillingEngine->>Model: entry.details.get("description")
Note over Model: Helyes: details JSONB mezőből olvas
Model-->>BillingEngine: description string
BillingEngine-->>FastAPI: {"earned": X, "purchased": Y, ...}
FastAPI-->>Client: 200 OK + JSON balances
```
**A hiba idején** a `.pyc` bytecode `entry.description`-t próbált elérni, ami nem létező attribútum a `FinancialLedger` modellen. Ez `AttributeError`-t okozott, amit a `billing.py` router `except Exception` ága elkapott és 500-ra fordított.