diff --git a/.codebase-memory/.gitattributes b/.codebase-memory/.gitattributes new file mode 100644 index 0000000..7ffe250 --- /dev/null +++ b/.codebase-memory/.gitattributes @@ -0,0 +1,3 @@ +# Auto-generated by codebase-memory-mcp +# Prevent merge conflicts on compressed artifact +graph.db.zst merge=ours binary diff --git a/.codebase-memory/artifact.json b/.codebase-memory/artifact.json new file mode 100644 index 0000000..14efd37 --- /dev/null +++ b/.codebase-memory/artifact.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "commit": "", + "indexed_at": "2026-07-12T14:46:44Z", + "project": "C-visual-studio-code-finansePRO", + "nodes": 789, + "edges": 1015, + "original_size": 2424832, + "compressed_size": 315382, + "compression_level": 9 +} \ No newline at end of file diff --git a/.codebase-memory/graph.db.zst b/.codebase-memory/graph.db.zst new file mode 100644 index 0000000..51b1c46 Binary files /dev/null and b/.codebase-memory/graph.db.zst differ diff --git a/.env b/.env index 4dd085b..1766c50 100644 --- a/.env +++ b/.env @@ -13,4 +13,11 @@ CI_ENVIRONMENT=production # --- PRODUKCJA (hostido) --- # Bez app.baseURL CI4 uzyje wartosci z app/Config/App.php (localhost:8080) i linki beda zle. app.baseURL = 'https://finanse.projectpro.pl/' -# Do lokalnego dev: CI_ENVIRONMENT=development + zakomentuj powyzsze app.baseURL. \ No newline at end of file +# Do lokalnego dev: CI_ENVIRONMENT=development + zakomentuj powyzsze app.baseURL. + +#Open AI +OPEN_AI_API=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 + +# Model OpenAI do importu paragonow. Domyslnie o4-mini (rozumujacy, najlepszy OCR paragonow + rabaty). +# Alternatywy: gpt-4o (drozszy, slabszy tu), gpt-4o-mini (tani, halucynuje na gestym druku). +# openai.model = o4-mini diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index f93f319..f89205c 100644 --- a/.paul/PROJECT.md +++ b/.paul/PROJECT.md @@ -1,21 +1,32 @@ # PROJECT — finansePRO -**Utworzono:** 2026-07-06 -**Status:** Inicjalizacja (pusty projekt) +**Status:** Aktywny, plan-first -## Nazwa -finansePRO +## Opis -## Krótki opis -Projekt na wczesnym etapie — katalog nie zawiera jeszcze kodu (poza konfiguracją `.vscode`). Cel i zakres do doprecyzowania. +Jednoosobowa aplikacja finansowa w CodeIgniter 4.7.3 i MySQL do ewidencji finansów bieżących, inwestycji oraz półautomatycznego importu paragonów. -## Wartość podstawowa -_Do uzupełnienia — czego finansePRO ma dostarczać użytkownikowi._ +## Dostarczone funkcje -## Aktualny status -- Repozytorium bez kodu źródłowego. -- PAUL zainicjalizowany w trybie plan-first. -- Brak zdefiniowanych wymagań i stosu technologicznego. +- ✓ Wydatki, przychody, kategorie i pulpity finansowe — Plan 1. +- ✓ Instrumenty, operacje, wyceny i pulpity inwestycji — Plan 2. +- ✓ Import paragonów przez OpenAI Responses API z podpowiedziami kategorii — Plan 3. +- ✓ Zapamiętywanie logowania przez 30 dni na wielu urządzeniach — Plan 4. -## Trwałe wymagania / ograniczenia -_Brak znanych na tę chwilę. Uzupełnij przy pierwszym `$paul-plan` lub `$paul-discuss`._ +## Trwałe wymagania i ograniczenia + +- Jedno konto z `.env`; brak systemu wielu użytkowników. +- MySQL na hostido; nowe migracje są stosowane ręcznie na zdalnej bazie. +- Lokalnie może brakować `vendor/`; wtedy dostępne jest `php -l`, a testy runtime wykonuje się w pełnym środowisku. +- Tokeny logowania są niezależne per urządzenie i przechowywane w bazie tylko jako hashe walidatorów. + +## Kluczowe decyzje + +| Data | Decyzja | Powód | +|------|---------|-------| +| 2026-07-12 | Osobny token „remember me” per urządzenie | Równoległe logowania bez wydłużania globalnej sesji | +| 2026-07-12 | Wylogowanie usuwa tylko bieżący token | Inne komputery mają pozostać zalogowane | +| 2026-07-12 | Bez panelu urządzeń i „wyloguj wszędzie” | YAGNI; brak takiego wymagania | + +--- +*Last updated: 2026-07-12 after Plan 20260712-1647* diff --git a/.paul/STATE.md b/.paul/STATE.md index 7266c5a..e0013df 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -1,35 +1,46 @@ # STATE — finansePRO -**Aktualizacja:** 2026-07-06T14:55 +**Aktualizacja:** 2026-07-12T17:31:28+02:00 ## Project Reference -CodeIgniter 4.7.3 + MySQL (baza `host700513_finansepro` na hostido, czytana z `.env`). PHP 8.2 (XAMPP), Bootstrap 5 + ApexCharts (CDN). Logowanie: jeden użytkownik z `.env`. Tryb PAUL: plan-first. + +CodeIgniter 4.7.3 + MySQL na hostido. Jedno konto z `.env`. Tryb PAUL: plan-first. ## Current Position -- Plan `20260706-1248-fundament-wydatki-przychody`: **UKOŃCZONY**. -- Plan `20260706-1430-modul-inwestycji`: **UKOŃCZONY** (PLAN → APPLY → UNIFY). Wszystkie AC (AC-1..AC-5) Pass; SUMMARY utworzone. -- Gotowe do kolejnego planu. + +- Plan `20260712-1647-zapamietywanie-logowania`: **UKOŃCZONY**. +- Status: PLAN → APPLY → UNIFY zamknięte; gotowe do następnego planu. +- Dostarczono tokeny logowania ważne 30 dni, niezależne dla wielu urządzeń. +- Zdalna tabela `remember_tokens` została utworzona i zweryfikowana. ## Loop Position + ``` PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [Pętla zamknięta — gotowe do następnego PLAN] ``` ## Session Continuity -- Ostatnia aktywność: UNIFY planu 2 — SUMMARY + changelog + radar + STATE. Moduł inwestycji + pulpit globalny `/dashboard` + pulpit finansów `/finances`. Dane testowe wyczyszczone, `.env` na `production`. -- Next action: `$paul-plan` dla kolejnego planu, lub wdrożenie produkcyjne (patrz Aktywne sprawy). -- Resume file: `.paul/plans/20260706-1430-modul-inwestycji/SUMMARY.md` -## Roadmapa (nieformalna) -- ✓ Plan 1: fundament + wydatki/przychody bieżące. -- ✓ Plan 2: moduł inwestycji (instrumenty, wpłaty/wypłaty, wyceny) + pulpit inwestycji + pulpit globalny. -- ▷ Kolejne: do doprecyzowania (np. wykres per instrument, eksport, historia wartości). +- Last session: 2026-07-12T17:31:28+02:00 +- Stopped at: UNIFY planu zapamiętywania logowania. +- Next action: opcjonalnie potwierdzić UAT w dwóch profilach albo uruchomić `$paul-plan` dla kolejnej funkcji. +- Resume file: `.paul/plans/20260712-1647-zapamietywanie-logowania/SUMMARY.md` ## Aktywne sprawy / do weryfikacji -- Wdrożenie produkcyjne `https://finanse.projectpro.pl/`: wgrać pliki + `vendor/`, uruchomić `php spark migrate` na serwerze (3 nowe tabele `inv_*`), root `.htaccess`/docroot→`public/`, prawa `writable/`, `.env` z `CI_ENVIRONMENT=production` + `app.baseURL`. -- Lokalny dev: `.env` bywa przełączany na `CI_ENVIRONMENT=development` (używa `db_host_remote`, bo lokalny MySQL zgaszony); po pracy wraca na `production`. Aktualnie: `production`. + +- UAT: dwa profile przeglądarki, zamknięcie zwykłej sesji oraz wylogowanie tylko jednego urządzenia. +- `vendor/bin/phpunit` nie jest dostępny lokalnie; test `RememberTokenModelTest.php` został tylko sprawdzony przez `php -l`. +- Deploy hostido nie uruchamia migracji automatycznie; tabela dla tego planu została już utworzona zdalnie. + +## Decisions + +- Token per urządzenie; kolejne logowanie nie usuwa wcześniejszych tokenów. +- Wylogowanie usuwa wyłącznie token bieżącego cookie. +- Bez panelu urządzeń i globalnego wylogowania do czasu realnej potrzeby. ## Uwagi -- Radar: degraded (weryfikacja runtime zamiast pełnego indeksu). Pełny `codebase-memory-mcp` przez `$paul-map-codebase`. -- Repo nie jest git — brak commitów fazowych. + +- Radar post-apply: degraded z powodu niepełnego wyniku indeksu i braku PHPUnit. +- Repozytorium Git ma liczne wcześniejsze i niezwiązane zmiany; commit fazowy pominięty zgodnie z `preferences.auto_commit: false`. +- `ROADMAP.md` nie istnieje i pozostaje opcjonalny w trybie plan-first. diff --git a/.paul/changelog/2026-07-06.md b/.paul/changelog/2026-07-06.md index 36dfa50..8188e2f 100644 --- a/.paul/changelog/2026-07-06.md +++ b/.paul/changelog/2026-07-06.md @@ -18,6 +18,14 @@ - Pulpit globalny `/dashboard` (majątek razem = saldo bieżące + wartość inwestycji); dotychczasowy pulpit finansów przepięty pod `/finances`. - Nawigacja zgrupowana w dropdowny: „Finanse bieżące" i „Inwestycje" + top-level „Pulpit". - Weryfikacja end-to-end curl (ETF: netto 800, wartość 950, zarobek 150 = 18,75%); dane testowe wyczyszczone. +- [Plan 20260706-2046] Półautomatyczny import paragonów: upload (jpg/png/pdf/json) → AI parsuje → przegląd/korekta → księgowanie pogrupowane po kategorii. +- Parser OpenAI przez **Responses API** (`/v1/responses` + `input_file`), model **o4-mini** (rozumujący — najlepszy OCR paragonów, poprawne rabaty; > gpt-4o/4.1). +- Tabele `receipts`, `receipt_items` (+`display_name`), `receipt_category_map`; kolumna `operations.receipt_id`. +- Uczone podpowiedzi kategorii (surowa nazwa z paragonu → kategoria; tylko podpowiada, nie księguje z automatu). +- Dwie nazwy pozycji: surowa (mapowanie) + ładna „ludzka" (wyświetlanie/opis operacji). +- Przegląd z edytowalnymi nazwami/kwotami + podgląd sum na żywo; kategorie z pełną ścieżką „Nadrzędna → Podrzędna". +- Naprawy pod hostido: przepływ bez `fileinfo` (własna nazwa pliku + MIME z rozszerzenia); migracje zastosowane ręcznie na zdalnej bazie. +- UAT realnym paragonem (Biedronka PDF) potwierdzony przez użytkownika. ## Zmienione pliki @@ -37,3 +45,8 @@ - `app/Views/instruments/{index,form}.php`, `app/Views/inv_operations/{index,form}.php`, `app/Views/valuations/{index,form}.php`, `app/Views/investments/index.php` - `app/Views/dashboard/index.php` (globalny), `app/Views/dashboard/finances.php` (finansowy) - `app/Config/Routes.php`, `app/Views/layout/main.php` +- `app/Database/Migrations/2026-07-06-2000{01..05}_*.php` (receipts, receipt_items, receipt_category_map, operations.receipt_id, display_name) +- `app/Models/{ReceiptModel,ReceiptItemModel,ReceiptCategoryMapModel}.php`, `app/Models/OperationModel.php`, `app/Models/CategoryModel.php` +- `app/Libraries/ReceiptParser.php` +- `app/Controllers/Receipts.php` +- `app/Views/receipts/{new,review,index,show}.php` diff --git a/.paul/changelog/2026-07-12.md b/.paul/changelog/2026-07-12.md new file mode 100644 index 0000000..ef9f36d --- /dev/null +++ b/.paul/changelog/2026-07-12.md @@ -0,0 +1,18 @@ +# 2026-07-12 + +## Co zrobiono + +- [Plan 20260712-1647] Dodano zapamiętywanie logowania przez 30 dni. +- Dodano niezależne, hashowane tokeny dla wielu komputerów. +- Podłączono checkbox, odtwarzanie sesji i selektywne wylogowanie. +- Utworzono tabelę `remember_tokens` na zdalnej bazie. +- Dodano test modelu; jego uruchomienie odroczono z powodu braku lokalnego `vendor/`. + +## Zmienione pliki + +- `app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php` +- `app/Models/RememberTokenModel.php` +- `app/Controllers/Auth.php` +- `app/Filters/AuthFilter.php` +- `app/Views/auth/login.php` +- `tests/unit/RememberTokenModelTest.php` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md new file mode 100644 index 0000000..4308b1e --- /dev/null +++ b/.paul/codebase/architecture.md @@ -0,0 +1,18 @@ +# Architecture + +## Stos + +- CodeIgniter 4.7.3, PHP 8.2, MySQL, Bootstrap 5. +- Jedno konto aplikacji pobierane z `.env` (`user_email`, `user_password`). + +## Uwierzytelnianie + +- `Auth::login` tworzy zwykłą sesję; opcjonalnie wystawia trwały token. +- `AuthFilter::before` chroni trasy i może odtworzyć sesję z cookie `remember_token`. +- `Auth::logout` usuwa sesję oraz token tylko bieżącego urządzenia. +- `RememberTokenModel` jest jednym źródłem logiki tokenów i czasu życia 30 dni. + +## Granice + +- Brak tabeli użytkowników i systemu wielu kont. +- Moduły finansów, inwestycji i paragonów korzystają wyłącznie z wyniku filtra `auth`. diff --git a/.paul/codebase/db_schema.md b/.paul/codebase/db_schema.md new file mode 100644 index 0000000..a7650aa --- /dev/null +++ b/.paul/codebase/db_schema.md @@ -0,0 +1,14 @@ +# Database Schema + +## `remember_tokens` + +| Kolumna | Typ | Uwagi | +|---------|-----|-------| +| `id` | INT UNSIGNED | Primary key, auto increment | +| `selector` | VARCHAR(32) | Unique, jawna część identyfikacyjna tokenu | +| `validator_hash` | CHAR(64) | SHA-256 walidatora; surowy walidator nie jest zapisywany | +| `expires_at` | DATETIME | Indeksowany termin ważności | +| `created_at` | DATETIME NULL | Timestamp CodeIgniter | +| `updated_at` | DATETIME NULL | Timestamp CodeIgniter | + +Tabela przechowuje wiele aktywnych rekordów, dzięki czemu urządzenia pozostają zalogowane niezależnie. diff --git a/.paul/codebase/impact_map.md b/.paul/codebase/impact_map.md index 3325a68..b63b06b 100644 --- a/.paul/codebase/impact_map.md +++ b/.paul/codebase/impact_map.md @@ -1,5 +1,19 @@ # Impact Map +## Post-apply 20260712-1647 +- Mechanizm wdrożony w kontrolerze logowania, filtrze, widoku, modelu i migracji. +- Zdalna tabela `remember_tokens` istnieje. +- Trasy i globalna konfiguracja sesji pozostały bez zmian. +- Do ręcznego potwierdzenia pozostaje UAT dwóch profili przeglądarki. + +## Plan 20260712-1647 — zapamiętywanie logowania (plan scan) +- Logowanie: `app/Controllers/Auth.php`, `app/Views/auth/login.php`. +- Odtworzenie sesji: `app/Filters/AuthFilter.php`; trasy pozostają bez zmian. +- Dane: nowa tabela tokenów i model; wiele rekordów umożliwia wiele urządzeń. +- Konfiguracja: użyć istniejących ustawień HttpOnly/SameSite; nie wydłużać `Session::$expiration`. +- Test/UAT: dwa profile, wylogowanie jednego urządzenia, nieważny i wygasły token. +- Wdrożenie: migrację zastosować ręcznie na hostido przed kodem. + **Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded) ## Kontekst @@ -16,6 +30,15 @@ CodeIgniter 4.7.3 + MySQL (`.env`). Radar degraded — brak indeksu codebase-mem - Wyceny: `ValuationModel`, `Valuations.php`, widoki `valuations/`, tabela `inv_valuations` (FK RESTRICT → inv_instruments). - Pulpit inwestycji: `Investments::index` → `investments/index.php`, trasa `/investments`. +### Import paragonów (Plan 3) +- Paragony: `ReceiptModel` (`withCounts`), `ReceiptItemModel` (`forReceipt`, `name` surowa + `display_name` ładna), `Receipts.php`, widoki `receipts/`, tabele `receipts` + `receipt_items` (FK CASCADE → receipts, SET NULL → categories). +- Uczone dopasowania: `ReceiptCategoryMapModel` (`normalize`, `suggest`, `remember`), tabela `receipt_category_map` (item_name UNIQUE → category_id). Klucz = surowa nazwa z paragonu. Tylko podpowiedź, brak auto-zapisu. +- Parser AI: `app/Libraries/ReceiptParser.php` — OpenAI **Responses API** (`/v1/responses`) przez `\Config\Services::curlrequest()`; jpg/png (`input_image`), pdf (`input_file` — OpenAI renderuje strony), json (`input_text`). Model z `env('openai.model')` domyślnie **o4-mini** (rozumujący; mini/gpt-4o za słabe na gęsty druk). Zwraca `name`+`display`. Klucz `OPEN_AI_API`. +- Upload bez `fileinfo` (hostido je wyłącza): walidacja rozszerzeń ręczna, własna nazwa pliku, MIME z rozszerzenia. Cały przepływ w try/catch → błąd zapisany w `receipts.error_msg`, nie 500. +- Zapis: `confirm()` grupuje pozycje po kategorii → 1 operacja `expense`/kategoria (suma edytowalnych kwot) przez `OperationModel`, z `operations.receipt_id` (FK SET NULL). Uczenie na surowej nazwie, opis z ładnych. Transakcja DB. +- Kategorie: `CategoryModel::tree()` daje `path_label` („Nadrzędna → Podrzędna") — używane w przeglądzie paragonu; `indent_label` nadal w operacjach. +- Pliki skanów: `writable/uploads/receipts/`, serwowane przez `Receipts::file` pod filtrem `auth`. + ### Pulpit globalny - `Dashboard::index` → `app/Views/dashboard/index.php`, trasy `/` i `/dashboard`. Majątek = saldo bieżące (`OperationModel::totals`) + wartość inwestycji (`InstrumentModel::withStats`). diff --git a/.paul/codebase/quality_risks.md b/.paul/codebase/quality_risks.md index ab59d85..bd36598 100644 --- a/.paul/codebase/quality_risks.md +++ b/.paul/codebase/quality_risks.md @@ -1,5 +1,18 @@ # Quality Risks +## Post-apply 20260712-1647 +- **Obsłużone:** osobne tokeny urządzeń, hash walidatora, `hash_equals`, selektywne wylogowanie. +- **Obsłużone operacyjnie:** tabela utworzona na zdalnej bazie po wykryciu błędu braku migracji. +- **Odroczone:** PHPUnit i UAT dwóch profili; brak lokalnego `vendor/` i potwierdzenia użytkownika. + +## Plan 20260712-1647 — zapamiętywanie logowania +- **Sekret tokenu:** w bazie wyłącznie hash walidatora; porównanie przez `hash_equals`. +- **Wiele urządzeń:** nowe logowanie dopisuje token, nie zastępuje aktywnych tokenów. +- **Wylogowanie:** unieważnia tylko token bieżącego cookie; sesja i cookie są usuwane. +- **Cookie:** 30 dni, HttpOnly, SameSite=Lax, Secure na produkcji. +- **Jedno źródło czasu życia:** stała w modelu tokenów. +- **YAGNI:** bez panelu urządzeń, harmonogramu i globalnego wylogowania. + **Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded) ## Zaakceptowane świadomie @@ -15,5 +28,14 @@ - Pełna indeksacja `codebase-memory-mcp` — `$paul-map-codebase`. - jscpd/ast-grep — wyłączone polityką w `config.md` (skan on-demand). +## Import paragonów (Plan 3) +- **Zapis operacji przez `OperationModel`** — bez duplikacji walidacji/logiki; `receipt_id` dodany do `allowedFields`. Jedno źródło prawdy zachowane. +- **Format kwoty `$fmt`** powtórzony w widokach `receipts/` — świadomie spójny z resztą (YAGNI, jak w Plan 1/2). +- **Podpowiedzi kategorii**: priorytet `receipt_category_map` (nauczone) > propozycja AI; NIC nie księgowane bez `confirm()` użytkownika (wymóg twardy spełniony). +- **Klucz OpenAI** czytany z `.env` (`OPEN_AI_API`); nie logowany, nie w repo. Model konfigurowalny (`openai.model`). +- **OCR paragonów niedeterministyczny**: wiersze rabatu/gęsty druk bywają źle odczytane (waha się między próbami). Świadomie kryte edytowalnymi kwotami/nazwami w przeglądzie (pół-automat). o4-mini najdokładniejszy z testowanych (suma pozycji = total co do grosza), gpt-4o/4.1 gorzej. +- **Mechanizm PDF**: konieczne **Responses API + input_file** — chat-completions `file_data` NIE dostarcza treści PDF (model halucynuje). Rozstrzygnięte i wdrożone. +- **Do obserwacji**: `normalize()` robi dokładne dopasowanie surowej nazwy — przy rozbieżnych nazwach tej samej rzeczy podpowiedź się nie pojawi; rozbudować gdy za wąska. Koszt tokenów OpenAI per import (o4-mini). Ewent. zapamiętywanie ładnej nazwy w `receipt_category_map`. + ## Do obserwacji - `portfolioTimeline` liczony w PHP O(daty × operacje) — trywialne przy ręcznych wycenach; przy dużej liczbie wpisów rozważyć agregację SQL. diff --git a/.paul/codebase/radar/codebase-memory-plan.txt b/.paul/codebase/radar/codebase-memory-plan.txt new file mode 100644 index 0000000..28ba140 --- /dev/null +++ b/.paul/codebase/radar/codebase-memory-plan.txt @@ -0,0 +1,15 @@ +Tryb: plan +Data: 2026-07-12T16:47:19+02:00 +Projekt: C-visual-studio-code-finansePRO +Status indeksu: indexed, moderate, 789 nodes, 1015 edges + +Zapytania: logowanie, sesja, filtr auth, logout, remember. + +Najważniejsze pliki: +- `app/Controllers/Auth.php` +- `app/Filters/AuthFilter.php` +- `app/Views/auth/login.php` +- `app/Config/Session.php` +- `app/Config/Cookie.php` + +Wniosek: osobne trwałe tokeny per urządzenie odtwarzające istniejącą sesję; bez wydłużania globalnej sesji. diff --git a/.paul/codebase/radar/codebase-memory-post-apply.txt b/.paul/codebase/radar/codebase-memory-post-apply.txt new file mode 100644 index 0000000..15e2b4b --- /dev/null +++ b/.paul/codebase/radar/codebase-memory-post-apply.txt @@ -0,0 +1,12 @@ +Tryb: post-apply +Data: 2026-07-12T17:31:28+02:00 +Projekt: C-visual-studio-code-finansePRO + +- Indeks moderate odświeżony: 789 nodes, 1015 edges. +- `search_graph` nie zwrócił nowych symboli `RememberTokenModel`. +- `search_code` zakończył się błędem tworzenia pliku tymczasowego. +- Fallback: bezpośredni lint 6 zmienionych plików — wszystkie poprawne. +- Zdalna tabela `remember_tokens`: 6 kolumn, 3 indeksy. +- PHPUnit: niedostępny, brak `vendor/bin/phpunit`. + +Status: degraded — kod i schemat sprawdzone, brak automatycznego testu runtime. diff --git a/.paul/codebase/tooling_status.md b/.paul/codebase/tooling_status.md index 8b41cc4..6db5448 100644 --- a/.paul/codebase/tooling_status.md +++ b/.paul/codebase/tooling_status.md @@ -1,16 +1,11 @@ # Tooling Status -**Timestamp:** 2026-07-06T13:10 -**Scan mode:** post-apply (plan 20260706-1248-fundament-wydatki-przychody) - -## Narzędzia -- `codebase-memory-mcp`: enabled, indeks nieodświeżony (ponytail: pełna indeksacja odłożona do `$paul-map-codebase`; kod świeżo dodany, weryfikacja przeszła runtime'owo). -- `jscpd`: disabled by policy -- `ast-grep`: disabled by policy - -## Weryfikacja runtime (zamiast skanu statycznego) -- `php spark migrate` / `db:seed` — OK (zdalna baza hostido). -- `php spark serve` + curl: login/logout, CRUD kategorii i operacji, filtry, sumy, 3 wykresy dashboardu, blokada usunięcia używanej kategorii, wymuszenie typu operacji przez kategorię — wszystko potwierdzone. - -## Następna akcja -`$paul-map-codebase` (pełny indeks codebase-memory-mcp) po ustabilizowaniu modułu, lub przed Planem 2 (inwestycje). +- Timestamp: 2026-07-12T17:31:28+02:00 +- Scan mode: post-apply +- codebase-memory-mcp: degraded; indeks odświeżony, ale nowe symbole nie zostały zwrócone, a `search_code` zgłosił błąd pliku tymczasowego +- fallback: `php -l` dla 6 plików — pass +- PHPUnit: unavailable (`vendor/bin/phpunit` missing) +- jscpd: disabled by policy +- ast-grep: disabled by policy +- Raw output: `.paul/codebase/radar/codebase-memory-post-apply.txt` +- Next action: opcjonalny UAT dwóch profili przeglądarki. diff --git a/.paul/plans/20260706-2046-import-paragonow/PLAN.md b/.paul/plans/20260706-2046-import-paragonow/PLAN.md new file mode 100644 index 0000000..388074b --- /dev/null +++ b/.paul/plans/20260706-2046-import-paragonow/PLAN.md @@ -0,0 +1,335 @@ +--- +plan_id: 20260706-2046-import-paragonow +title: Półautomatyczny import paragonów (AI parsowanie + uczone podpowiedzi kategorii) +storage: plan-first +legacy_phase: null +created: 2026-07-06T20:46 +status: planned +type: execute +autonomous: false +delegation: auto +files_modified: + - .env + - app/Config/Routes.php + - app/Database/Migrations/2026-07-06-200001_CreateReceipts.php + - app/Database/Migrations/2026-07-06-200002_CreateReceiptItems.php + - app/Database/Migrations/2026-07-06-200003_CreateReceiptCategoryMap.php + - app/Database/Migrations/2026-07-06-200004_AddReceiptIdToOperations.php + - app/Models/ReceiptModel.php + - app/Models/ReceiptItemModel.php + - app/Models/ReceiptCategoryMapModel.php + - app/Libraries/ReceiptParser.php + - app/Controllers/Receipts.php + - app/Views/receipts/new.php + - app/Views/receipts/review.php + - app/Views/receipts/index.php + - app/Views/receipts/show.php + - app/Views/layout/main.php +quality_radar: degraded +--- + + +## Cel +Dodać moduł półautomatycznego importu paragonów: użytkownik wgrywa paragon (jpg/png, pdf lub json), +agent AI (OpenAI `gpt-4o-mini`) parsuje pozycje i dopasowuje je do istniejących kategorii wydatków. +Na ekranie przeglądu pozycje są **grupowane po kategorii**; użytkownik poprawia przypisania i zatwierdza. +Po zatwierdzeniu powstaje **jedna operacja (wydatek) na kategorię** (suma pozycji), a wybory zasilają +tabelę uczenia `receipt_category_map` (nazwa pozycji → kategoria) — przy kolejnych paragonach ta sama +pozycja dostaje podpowiedź, ale **nigdy nie jest zapisywana automatycznie**. + +## Uzasadnienie +Ręczne przepisywanie paragonów pozycja po pozycji jest żmudne. AI + rosnąca pamięć dopasowań skraca +import do kilku kliknięć akceptacji, zachowując pełną kontrolę użytkownika (nic nie ląduje w `operations` +bez potwierdzenia). + +## Wynik +- Nowa zakładka „Import paragonów" (w dropdownie „Finanse bieżące"). +- 3 nowe tabele (`receipts`, `receipt_items`, `receipt_category_map`) + kolumna `operations.receipt_id`. +- Warstwa `ReceiptParser` do komunikacji z OpenAI (bez nowej zależności — CI4 CURLRequest). +- Historia importów z podglądem wgranego skanu. + + + +## Project Docs +@.paul/PROJECT.md +@.paul/STATE.md +@.paul/codebase/impact_map.md +@.paul/codebase/quality_risks.md + +## Source Files (wzorce do naśladowania) +@app/Controllers/Operations.php +@app/Models/OperationModel.php +@app/Models/CategoryModel.php +@app/Config/Routes.php +@app/Views/layout/main.php + + + +Ustalone z użytkownikiem (2026-07-06): +1. **Granularość:** pozycje grupowane po kategorii → jedna operacja `expense` na kategorię (suma pozycji). + Uczenie działa na poziomie pojedynczej pozycji (`item_name` → `category_id`), nie na całym paragonie. +2. **Historia:** pełna — trwałe tabele `receipts` + `receipt_items`, plik skanu trzymany w `writable/uploads/receipts/`. +3. **Model AI:** `gpt-4o-mini`, klucz `OPEN_AI_API` już w `.env`; nazwa modelu czytana z `.env` (łatwa podmiana). +4. **Formaty:** jpg/png (vision), json (tekst), pdf (OpenAI file input — bez lokalnej rasteryzacji). +5. **Podpowiedzi:** priorytet `receipt_category_map` (nauczone) > propozycja AI. Obie tylko sugerują. + + + +## Quality Radar + +**Status:** degraded (brak indeksu codebase-memory-mcp; skan runtime + grep; jscpd/ast-grep wyłączone polityką w config.md) +**Tools:** grep/Read; codebase-memory-mcp niezaindeksowany (`$paul-map-codebase` odłożone) + +## Affected Areas + +- **Finanse bieżące (operations/categories):** nowy moduł zapisuje do `operations` (`type=expense`, `category_id`) — + ta sama tabela i walidacja co ręczne operacje. Kategorie czytane przez `CategoryModel::flatList('expense')`. +- **Routing / nawigacja:** nowa grupa tras `receipts/*` w `app/Config/Routes.php` (grupa `auth`); nowy link w `layout/main.php`. +- **Storage:** pliki skanów w `writable/uploads/receipts/` (poza `public/`); serwowane przez akcję kontrolera pod filtrem `auth`. +- **Konfiguracja:** `.env` — `OPEN_AI_API` (istnieje) + opcjonalny `openai.model`. + +## Duplicate / Hardcoded Risks + +- **Format kwoty `$fmt`** (`number_format(...,2,',',' ').' zł'`) — powtórzony w widokach (świadomie zaakceptowane w quality_risks.md). + Handled: w nowych widokach użyć tego samego lokalnego wzorca `$fmt`, nie tworzyć helpera (spójność z resztą, YAGNI). +- **Zapis do `operations`** — NIE duplikować logiki walidacji; użyć `OperationModel` (jedno źródło prawdy dla operacji). Handled w Task 5. +- **Wzorce zapytań filtered/join** — nie dotyczy; nowe modele są proste (find/insert/upsert). + +## Explicit Deferrals + +- Pełna indeksacja codebase-memory-mcp — odłożone (`$paul-map-codebase`), zgodnie z istniejącym stanem radaru. +- Rasteryzacja PDF lokalnie (Imagick/Ghostscript) — niepotrzebna: PDF idzie file-inputem do OpenAI. Odłożone jako niebyt. +- Automatyczna kategoryzacja bez potwierdzenia — świadomie POZA zakresem (wymóg: tylko podpowiedź). + + + +Brak `.paul/SPECIAL-FLOWS.md` — sekcja skills pominięta. + + + + +## AC-1: Formularz uploadu przyjmuje jpg/png/pdf/json +```gherkin +Given zalogowany użytkownik na /receipts/new +When wgrywa plik .jpg / .png / .pdf / .json o dozwolonym rozmiarze +Then plik zostaje zapisany w writable/uploads/receipts/ z bezpieczną, losową nazwą +And powstaje rekord w tabeli receipts ze statusem 'parsing' → 'parsed' +And niedozwolony typ/rozmiar jest odrzucany z czytelnym komunikatem (bez zapisu) +``` + +## AC-2: AI parsuje paragon i zwraca pozycje z propozycją kategorii +```gherkin +Given wgrany paragon i lista kategorii wydatków przekazana do modelu +When ReceiptParser wywołuje OpenAI gpt-4o-mini z wymuszonym JSON (response_format) +Then otrzymujemy merchant, date, total oraz items[] (name, qty, amount, suggested_category) +And każda pozycja trafia do receipt_items z suggested_category_id rozwiązanym z odpowiedzi AI +And błąd/timeout API ustawia receipt.status='failed' i pokazuje komunikat, nie tworzy operacji +``` + +## AC-3: Nauczone dopasowanie ma priorytet nad AI i tylko podpowiada +```gherkin +Given pozycja o nazwie występującej wcześniej w receipt_category_map +When budowany jest ekran przeglądu +Then podpowiedziana kategoria pochodzi z receipt_category_map (priorytet nad AI) +And żadna operacja ani mapowanie nie są zapisane dopóki użytkownik nie zatwierdzi +``` + +## AC-4: Ekran przeglądu grupuje pozycje po kategorii i pozwala poprawić +```gherkin +Given sparsowany paragon na /receipts/(:num)/review +When użytkownik widzi pozycje z selectem kategorii (podpowiedź zaznaczona) +Then pozycje są zwizualizowane pogrupowane po kategorii z sumą per grupa +And użytkownik może zmienić kategorię każdej pozycji lub ją pominąć (bez kategorii = nie księgowana) +``` + +## AC-5: Zatwierdzenie tworzy operacje zbiorczo i uczy mapowań +```gherkin +Given zweryfikowany paragon z przypisanymi kategoriami +When użytkownik klika „Zaksięguj" +Then dla każdej kategorii powstaje jedna operacja expense = suma jej pozycji, z receipt_id i opisem (merchant + lista pozycji) +And dla każdej pozycji z wybraną kategorią wykonywany jest upsert receipt_category_map (name → category_id) +And receipt.status='imported'; ponowne księgowanie tego samego paragonu jest zablokowane +``` + +## AC-6: Historia i podgląd skanu +```gherkin +Given zaimportowane/wgrane paragony +When użytkownik otwiera /receipts +Then widzi listę (data, sprzedawca, suma, status, liczba operacji) z akcjami podgląd/usuń +And podgląd skanu serwowany jest tylko dla zalogowanego (filtr auth), plik spoza public/ +``` + + + + + + + Task 1: Migracje — receipts, receipt_items, receipt_category_map, operations.receipt_id + app/Database/Migrations/2026-07-06-200001_CreateReceipts.php, app/Database/Migrations/2026-07-06-200002_CreateReceiptItems.php, app/Database/Migrations/2026-07-06-200003_CreateReceiptCategoryMap.php, app/Database/Migrations/2026-07-06-200004_AddReceiptIdToOperations.php + + Wzoruj się na istniejących migracjach (2026-07-06-140002 itd.). + - `receipts`: id, file_path VARCHAR(255), original_name VARCHAR(255), mime VARCHAR(100), + merchant VARCHAR(190) NULL, receipt_date DATE NULL, total DECIMAL(12,2) NULL, + status ENUM('parsing','parsed','failed','imported') DEFAULT 'parsing', + raw_json LONGTEXT NULL, error_msg VARCHAR(255) NULL, created_at, updated_at. + - `receipt_items`: id, receipt_id INT UNSIGNED, name VARCHAR(255), qty DECIMAL(10,3) NULL, + amount DECIMAL(12,2), suggested_category_id INT UNSIGNED NULL, chosen_category_id INT UNSIGNED NULL, + created_at, updated_at. FK receipt_id → receipts.id ON DELETE CASCADE. + FK suggested/chosen_category_id → categories.id ON DELETE SET NULL. + - `receipt_category_map`: id, item_name VARCHAR(255) (UNIQUE), category_id INT UNSIGNED, + updated_at. FK category_id → categories.id ON DELETE CASCADE. (item_name znormalizowane — patrz Task 3.) + - `operations`: dodać kolumnę receipt_id INT UNSIGNED NULL po category_id; FK → receipts.id ON DELETE SET NULL. + down() usuwa FK i kolumnę. + + php spark migrate; sprawdź 4 nowe struktury (SHOW TABLES / DESCRIBE operations) + AC-1, AC-5 (schema); zgodne z impact_map (jedno źródło prawdy operacji) + + + + Task 2: Modele — ReceiptModel, ReceiptItemModel, ReceiptCategoryMapModel + app/Models/ReceiptModel.php, app/Models/ReceiptItemModel.php, app/Models/ReceiptCategoryMapModel.php + + Wzoruj się na OperationModel/CategoryModel (returnType array, useTimestamps true, allowedFields). + - ReceiptModel: allowedFields dla wszystkich pól z Task 1; walidacja mime/status. + - ReceiptItemModel: allowedFields; metoda `forReceipt(int $receiptId): array`. + - ReceiptCategoryMapModel: + * `normalize(string $name): string` — trim, mb_strtolower, redukcja wielokrotnych spacji + (opcjonalnie obcięcie kończówek liczbowych/gramatur — MINIMALNIE, patrz ponytail). + * `suggest(string $name): ?int` — zwraca category_id dla znormalizowanej nazwy lub null. + * `remember(string $name, int $categoryId): void` — upsert po znormalizowanym item_name + (INSERT ... ON DUPLICATE KEY UPDATE lub find+update). + + Ręczny test w tinker/kontrolerze: remember('Mleko 3.2%', 5) → suggest(' mleko 3.2% ') === 5 + AC-2, AC-3 + + + + Task 3: ReceiptParser — warstwa OpenAI (jpg/png vision, json tekst, pdf file input) + app/Libraries/ReceiptParser.php, .env + + Klasa `App\Libraries\ReceiptParser` bez nowej zależności — użyj `\Config\Services::curlrequest()`. + - Klucz: `env('OPEN_AI_API')`. Model: `env('openai.model', 'gpt-4o-mini')` — dopisz zakomentowany + `# openai.model = gpt-4o-mini` do `.env` (klucz OPEN_AI_API już istnieje, nie ruszać wartości). + - Metoda `parse(string $filePath, string $mime, array $categories): array` zwraca: + ['merchant'=>?string,'date'=>?string,'total'=>?float,'items'=>[['name','qty','amount','category'=>?string], ...]]. + - Endpoint: POST https://api.openai.com/v1/chat/completions, `response_format` = json_schema (lub json_object) + wymuszający powyższą strukturę. W system/prompt przekaż listę nazw kategorii wydatków, aby AI mapowało + `category` do jednej z nich (dokładna nazwa albo null). + - Wejście wg mime: + * image/* → content image_url z data URL base64 (`data:{mime};base64,...`). + * application/pdf → OpenAI file input (typ input_file / base64 file) — bez lokalnej rasteryzacji. + * application/json (lub .json) → wczytaj tekst pliku, przekaż jako treść do interpretacji. + - Timeout ~60s; przy błędzie HTTP/parsowania rzuć wyjątek z czytelnym komunikatem (łapany w kontrolerze). + - `category` z odpowiedzi rozwiązywane do category_id po nazwie w kontrolerze (Task 4), nie tutaj. + // ponytail: bez SDK — jeden POST CURLRequest; jeśli pojawi się drugi endpoint, wtedy refaktor. + + Testowy jpg paragonu → parse() zwraca ≥1 pozycję z kwotą; błędny klucz → wyjątek z komunikatem + AC-2 + + + + Task 4: Kontroler Receipts + trasy — upload, parse, review + app/Controllers/Receipts.php, app/Config/Routes.php + + Kontroler wzorowany na Operations.php. Trasy w grupie `auth`: + GET receipts → index + GET receipts/new → new + POST receipts → create + GET receipts/(:num)/review → review/$1 + POST receipts/(:num)/confirm → confirm/$1 (Task 5) + GET receipts/(:num) → show + GET receipts/(:num)/file → file/$1 (serwowanie skanu, auth) + GET receipts/(:num)/delete → delete/$1 + - create(): walidacja uploadu (ext in jpg,jpeg,png,pdf,json; max_size np. 10240 KB; is_image lub mime whitelist). + Zapis pliku przez `$file->store('receipts', $file->getRandomName())` do writable/uploads/receipts/. + Utwórz receipt (status 'parsing'). Wywołaj ReceiptParser::parse() z `CategoryModel::flatList('expense')`. + Dla każdej pozycji: rozwiąż `category` (AI) → suggested_category_id po nazwie kategorii; + NADPISZ podpowiedź wynikiem `ReceiptCategoryMapModel::suggest(name)` gdy istnieje (priorytet nauczonego). + Zapisz receipt_items, raw_json, merchant/date/total, status 'parsed'; redirect → review. + Przy wyjątku parsera: status 'failed', error_msg; redirect → show z komunikatem błędu. + - review(): pobierz receipt + items + `CategoryModel::flatList('expense')`; przekaż do widoku. + Blokuj review gdy status='imported' (redirect → show). + - file(): odczyt pliku spoza public i zwrot z właściwym Content-Type (tylko pod filtrem auth). + - index()/show()/delete(): lista, podgląd, usunięcie (delete kasuje też plik z dysku; CASCADE czyści items). + + Upload testowego pliku → rekord receipt 'parsed' + wiersze receipt_items; /receipts/(:num)/review renderuje + AC-1, AC-2, AC-3, AC-6 + + + + Task 5: Zatwierdzenie — grupowanie po kategorii, zapis operacji, uczenie mapowań + app/Controllers/Receipts.php + + confirm(int $id): + - Pobierz z POST tablicę chosen_category_id per receipt_item (id → category_id, puste = pomiń pozycję). + - Zapisz chosen_category_id do receipt_items. + - Zablokuj podwójne księgowanie: jeśli receipt.status='imported' → redirect z komunikatem. + - Grupuj pozycje z wybraną kategorią po category_id; dla każdej grupy jedna operacja przez `OperationModel`: + date = receipt_date ?? today; amount = SUMA amount grupy; type='expense'; category_id; receipt_id=$id; + description = merchant + ': ' + lista nazw pozycji (skrócona). NIE duplikować walidacji — użyć OperationModel::insert. + - Dla każdej pozycji z wybraną kategorią: `ReceiptCategoryMapModel::remember(name, category_id)` (uczenie). + - status receipt = 'imported'; redirect → show z liczbą utworzonych operacji. + // ponytail: brak transakcji rozproszonej; jedno przejście w transakcji DB ($db->transStart/Complete) dla spójności. + + Paragon z 3 kategoriami → 3 operacje (sumy), wpisy w receipt_category_map; ponowny confirm zablokowany + AC-4, AC-5 + + + + Task 6: Widoki + nawigacja + app/Views/receipts/new.php, app/Views/receipts/review.php, app/Views/receipts/index.php, app/Views/receipts/show.php, app/Views/layout/main.php + + Rozszerz `layout/main.php` (extend, sekcja content — jak inne widoki). Użyj lokalnego wzorca `$fmt` do kwot (spójnie z resztą). + - new.php: form multipart z ``, + krótka instrukcja, przycisk „Wgraj i przetwórz". Pokaż flash error (walidacja). + - review.php: nagłówek (merchant, data, suma z AI). Lista pozycji: nazwa, kwota, ` +
+ + +
diff --git a/app/Views/layout/main.php b/app/Views/layout/main.php index 6d39c35..7f55f74 100644 --- a/app/Views/layout/main.php +++ b/app/Views/layout/main.php @@ -28,6 +28,7 @@ @@ -55,6 +56,9 @@ getFlashdata('success')): ?>
getFlashdata('success')) ?>
+ getFlashdata('warning')): ?> +
getFlashdata('warning')) ?>
+ renderSection('content') ?> diff --git a/app/Views/receipts/index.php b/app/Views/receipts/index.php new file mode 100644 index 0000000..1e6a151 --- /dev/null +++ b/app/Views/receipts/index.php @@ -0,0 +1,59 @@ +extend('layout/main') ?> + +section('content') ?> + number_format((float) $v, 2, ',', ' ') . ' zł'; +$badges = [ + 'parsing' => 'secondary', + 'parsed' => 'info', + 'failed' => 'danger', + 'imported' => 'success', +]; +?> + +
+

Import paragonów

+ + Nowy import +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DataSprzedawcaSumaPozycjeOperacjeStatusAkcje
Brak zaimportowanych paragonów.
+ + Przegląd + + Podgląd + + Usuń +
+
+
+endSection() ?> diff --git a/app/Views/receipts/new.php b/app/Views/receipts/new.php new file mode 100644 index 0000000..3bb1787 --- /dev/null +++ b/app/Views/receipts/new.php @@ -0,0 +1,27 @@ +extend('layout/main') ?> + +section('content') ?> +
+

Nowy import paragonu

+ ← Historia importów +
+ +
+
+ 'row g-3']) ?> +
+ + +
+ Obsługiwane formaty: skan JPG/PNG, PDF lub JSON. Maks. 10 MB. + Po wgraniu AI odczyta pozycje i zaproponuje kategorie do zatwierdzenia. +
+
+
+ +
+ +
+
+endSection() ?> diff --git a/app/Views/receipts/review.php b/app/Views/receipts/review.php new file mode 100644 index 0000000..aae0345 --- /dev/null +++ b/app/Views/receipts/review.php @@ -0,0 +1,116 @@ +extend('layout/main') ?> + +section('content') ?> + number_format((float) $v, 2, ',', ' ') . ' zł'; ?> + +
+

Przegląd paragonu

+ ← Historia importów +
+ +
+
+
Sprzedawca
+
Data
+
Suma z paragonu (AI)
+
+
+ + +
+
+ Pozycje — sprawdź i popraw nazwy/kwoty (AI potrafi się pomylić), przypisz kategorię + (podpowiedź zaznaczona; „— pomiń —” = nie księguj) +
+
+ + + + + + + + + + + + + + + + + + + + +
PozycjaKwota (zł)Kategoria
AI nie odczytało żadnych pozycji.
+ + z paragonu: + + + + +
+
+
+ +
+
Podgląd księgowania — jedna operacja na kategorię (suma)
+
+
+
+
+ +
+ + Usuń import +
+ +endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/app/Views/receipts/show.php b/app/Views/receipts/show.php new file mode 100644 index 0000000..984adf6 --- /dev/null +++ b/app/Views/receipts/show.php @@ -0,0 +1,77 @@ +extend('layout/main') ?> + +section('content') ?> + number_format((float) $v, 2, ',', ' ') . ' zł'; ?> + +
+

Paragon

+ ← Historia importów +
+ +
+
+
+
+
Data
+
Suma (AI)
+
Status
+
Plik + +
+ +
+ +
+
+ +
+
Pozycje
+
+ + + + + + + + + +
PozycjaKwota
Brak pozycji.
+
+
+ + +
+ +
+ +
+
Zaksięgowane operacje
+
    + +
  • + + +
  • + +
+
+ + + +
+
Skan
+
+ Skan paragonu +
+
+ +
+
+endSection() ?> diff --git a/operations-before.png b/operations-before.png new file mode 100644 index 0000000..430a343 Binary files /dev/null and b/operations-before.png differ diff --git a/tests/unit/ReceiptModelTest.php b/tests/unit/ReceiptModelTest.php new file mode 100644 index 0000000..28db6fb --- /dev/null +++ b/tests/unit/ReceiptModelTest.php @@ -0,0 +1,28 @@ +insert([ + 'file_path' => 'receipts/stored.pdf', + 'original_name' => 'paragon.pdf', + 'mime' => 'application/pdf', + 'status' => 'parsed', + ]); + + $this->assertTrue($model->hasOriginalName('paragon.pdf')); + $this->assertFalse($model->hasOriginalName('inny.pdf')); + } +} diff --git a/tests/unit/RememberTokenModelTest.php b/tests/unit/RememberTokenModelTest.php new file mode 100644 index 0000000..e0b6399 --- /dev/null +++ b/tests/unit/RememberTokenModelTest.php @@ -0,0 +1,34 @@ +issueToken(); + $second = $model->issueToken(); + + $this->assertTrue($model->verifyToken($first)); + $this->assertTrue($model->verifyToken($second)); + + $tampered = substr($first, 0, -1) . (str_ends_with($first, '0') ? '1' : '0'); + $this->assertFalse($model->verifyToken($tampered)); + + $model->deleteToken($first); + + $this->assertFalse($model->verifyToken($first)); + $this->assertTrue($model->verifyToken($second)); + } +}