This commit is contained in:
2026-07-12 19:21:35 +02:00
parent f2d944b117
commit 41cb412cb0
49 changed files with 3170 additions and 52 deletions
+3
View File
@@ -0,0 +1,3 @@
# Auto-generated by codebase-memory-mcp
# Prevent merge conflicts on compressed artifact
graph.db.zst merge=ours binary
+11
View File
@@ -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
}
Binary file not shown.
+8 -1
View File
@@ -13,4 +13,11 @@ CI_ENVIRONMENT=production
# --- PRODUKCJA (hostido) --- # --- PRODUKCJA (hostido) ---
# Bez app.baseURL CI4 uzyje wartosci z app/Config/App.php (localhost:8080) i linki beda zle. # Bez app.baseURL CI4 uzyje wartosci z app/Config/App.php (localhost:8080) i linki beda zle.
app.baseURL = 'https://finanse.projectpro.pl/' app.baseURL = 'https://finanse.projectpro.pl/'
# Do lokalnego dev: CI_ENVIRONMENT=development + zakomentuj powyzsze app.baseURL. # 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
+25 -14
View File
@@ -1,21 +1,32 @@
# PROJECT — finansePRO # PROJECT — finansePRO
**Utworzono:** 2026-07-06 **Status:** Aktywny, plan-first
**Status:** Inicjalizacja (pusty projekt)
## Nazwa ## Opis
finansePRO
## Krótki opis Jednoosobowa aplikacja finansowa w CodeIgniter 4.7.3 i MySQL do ewidencji finansów bieżących, inwestycji oraz półautomatycznego importu paragonów.
Projekt na wczesnym etapie — katalog nie zawiera jeszcze kodu (poza konfiguracją `.vscode`). Cel i zakres do doprecyzowania.
## Wartość podstawowa ## Dostarczone funkcje
_Do uzupełnienia — czego finansePRO ma dostarczać użytkownikowi._
## Aktualny status - ✓ Wydatki, przychody, kategorie i pulpity finansowe — Plan 1.
- Repozytorium bez kodu źródłowego. - ✓ Instrumenty, operacje, wyceny i pulpity inwestycji — Plan 2.
- PAUL zainicjalizowany w trybie plan-first. - ✓ Import paragonów przez OpenAI Responses API z podpowiedziami kategorii — Plan 3.
- Brak zdefiniowanych wymagań i stosu technologicznego. - ✓ Zapamiętywanie logowania przez 30 dni na wielu urządzeniach — Plan 4.
## Trwałe wymagania / ograniczenia ## Trwałe wymagania i ograniczenia
_Brak znanych na tę chwilę. Uzupełnij przy pierwszym `$paul-plan` lub `$paul-discuss`._
- 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*
+27 -16
View File
@@ -1,35 +1,46 @@
# STATE — finansePRO # STATE — finansePRO
**Aktualizacja:** 2026-07-06T14:55 **Aktualizacja:** 2026-07-12T17:31:28+02:00
## Project Reference ## 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 ## 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. - Plan `20260712-1647-zapamietywanie-logowania`: **UKOŃCZONY**.
- Gotowe do kolejnego planu. - 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 ## Loop Position
``` ```
PLAN ──▶ APPLY ──▶ UNIFY PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Pętla zamknięta — gotowe do następnego PLAN] ✓ ✓ ✓ [Pętla zamknięta — gotowe do następnego PLAN]
``` ```
## Session Continuity ## 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) - Last session: 2026-07-12T17:31:28+02:00
- ✓ Plan 1: fundament + wydatki/przychody bieżące. - Stopped at: UNIFY planu zapamiętywania logowania.
- ✓ Plan 2: moduł inwestycji (instrumenty, wpłaty/wypłaty, wyceny) + pulpit inwestycji + pulpit globalny. - Next action: opcjonalnie potwierdzić UAT w dwóch profilach albo uruchomić `$paul-plan` dla kolejnej funkcji.
- ▷ Kolejne: do doprecyzowania (np. wykres per instrument, eksport, historia wartości). - Resume file: `.paul/plans/20260712-1647-zapamietywanie-logowania/SUMMARY.md`
## Aktywne sprawy / do weryfikacji ## 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 ## 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.
+13
View File
@@ -18,6 +18,14 @@
- Pulpit globalny `/dashboard` (majątek razem = saldo bieżące + wartość inwestycji); dotychczasowy pulpit finansów przepięty pod `/finances`. - 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". - 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. - 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 ## 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/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/Views/dashboard/index.php` (globalny), `app/Views/dashboard/finances.php` (finansowy)
- `app/Config/Routes.php`, `app/Views/layout/main.php` - `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`
+18
View File
@@ -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`
+18
View File
@@ -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`.
+14
View File
@@ -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.
+23
View File
@@ -1,5 +1,19 @@
# Impact Map # 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) **Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded)
## Kontekst ## 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). - Wyceny: `ValuationModel`, `Valuations.php`, widoki `valuations/`, tabela `inv_valuations` (FK RESTRICT → inv_instruments).
- Pulpit inwestycji: `Investments::index``investments/index.php`, trasa `/investments`. - 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 ### Pulpit globalny
- `Dashboard::index``app/Views/dashboard/index.php`, trasy `/` i `/dashboard`. Majątek = saldo bieżące (`OperationModel::totals`) + wartość inwestycji (`InstrumentModel::withStats`). - `Dashboard::index``app/Views/dashboard/index.php`, trasy `/` i `/dashboard`. Majątek = saldo bieżące (`OperationModel::totals`) + wartość inwestycji (`InstrumentModel::withStats`).
+22
View File
@@ -1,5 +1,18 @@
# Quality Risks # 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) **Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded)
## Zaakceptowane świadomie ## Zaakceptowane świadomie
@@ -15,5 +28,14 @@
- Pełna indeksacja `codebase-memory-mcp``$paul-map-codebase`. - Pełna indeksacja `codebase-memory-mcp``$paul-map-codebase`.
- jscpd/ast-grep — wyłączone polityką w `config.md` (skan on-demand). - 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 ## Do obserwacji
- `portfolioTimeline` liczony w PHP O(daty × operacje) — trywialne przy ręcznych wycenach; przy dużej liczbie wpisów rozważyć agregację SQL. - `portfolioTimeline` liczony w PHP O(daty × operacje) — trywialne przy ręcznych wycenach; przy dużej liczbie wpisów rozważyć agregację SQL.
@@ -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.
@@ -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.
+9 -14
View File
@@ -1,16 +1,11 @@
# Tooling Status # Tooling Status
**Timestamp:** 2026-07-06T13:10 - Timestamp: 2026-07-12T17:31:28+02:00
**Scan mode:** post-apply (plan 20260706-1248-fundament-wydatki-przychody) - 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
## Narzędzia - fallback: `php -l` dla 6 plików — pass
- `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). - PHPUnit: unavailable (`vendor/bin/phpunit` missing)
- `jscpd`: disabled by policy - jscpd: disabled by policy
- `ast-grep`: disabled by policy - ast-grep: disabled by policy
- Raw output: `.paul/codebase/radar/codebase-memory-post-apply.txt`
## Weryfikacja runtime (zamiast skanu statycznego) - Next action: opcjonalny UAT dwóch profili przeglądarki.
- `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).
@@ -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
---
<objective>
## 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.
</objective>
<context>
## 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
</context>
<clarifications>
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ą.
</clarifications>
<impact_scan>
## 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ź).
</impact_scan>
<skills>
Brak `.paul/SPECIAL-FLOWS.md` — sekcja skills pominięta.
</skills>
<acceptance_criteria>
## 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/
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracje — receipts, receipt_items, receipt_category_map, operations.receipt_id</name>
<files>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</files>
<action>
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ę.
</action>
<verify>php spark migrate; sprawdź 4 nowe struktury (SHOW TABLES / DESCRIBE operations)</verify>
<done>AC-1, AC-5 (schema); zgodne z impact_map (jedno źródło prawdy operacji)</done>
</task>
<task type="auto">
<name>Task 2: Modele — ReceiptModel, ReceiptItemModel, ReceiptCategoryMapModel</name>
<files>app/Models/ReceiptModel.php, app/Models/ReceiptItemModel.php, app/Models/ReceiptCategoryMapModel.php</files>
<action>
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).
</action>
<verify>Ręczny test w tinker/kontrolerze: remember('Mleko 3.2%', 5) → suggest(' mleko 3.2% ') === 5</verify>
<done>AC-2, AC-3</done>
</task>
<task type="auto">
<name>Task 3: ReceiptParser — warstwa OpenAI (jpg/png vision, json tekst, pdf file input)</name>
<files>app/Libraries/ReceiptParser.php, .env</files>
<action>
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.
</action>
<verify>Testowy jpg paragonu → parse() zwraca ≥1 pozycję z kwotą; błędny klucz → wyjątek z komunikatem</verify>
<done>AC-2</done>
</task>
<task type="auto">
<name>Task 4: Kontroler Receipts + trasy — upload, parse, review</name>
<files>app/Controllers/Receipts.php, app/Config/Routes.php</files>
<action>
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).
</action>
<verify>Upload testowego pliku → rekord receipt 'parsed' + wiersze receipt_items; /receipts/(:num)/review renderuje</verify>
<done>AC-1, AC-2, AC-3, AC-6</done>
</task>
<task type="auto">
<name>Task 5: Zatwierdzenie — grupowanie po kategorii, zapis operacji, uczenie mapowań</name>
<files>app/Controllers/Receipts.php</files>
<action>
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.
</action>
<verify>Paragon z 3 kategoriami → 3 operacje (sumy), wpisy w receipt_category_map; ponowny confirm zablokowany</verify>
<done>AC-4, AC-5</done>
</task>
<task type="auto">
<name>Task 6: Widoki + nawigacja</name>
<files>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</files>
<action>
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 `<input type="file" accept=".jpg,.jpeg,.png,.pdf,.application/json,image/*,application/pdf">`,
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, `<select name="chosen_category_id[ID]">`
z `CategoryModel::flatList('expense')`, zaznaczona podpowiedź (chosen ?? suggested). Opcja pusta = „— pomiń —".
Sekcja podglądu „grupowanie po kategorii" z sumą per kategoria (JS liczy na żywo z selectów — vanilla, bez zależności).
Form POST → receipts/(:num)/confirm, przycisk „Zaksięguj".
- index.php: tabela historii (data, sprzedawca, suma, status badge, akcje: podgląd/usuń) + przycisk „Nowy import".
- show.php: dane paragonu, status, link/obraz skanu (`receipts/(:num)/file`), lista utworzonych operacji (jeśli imported),
komunikat błędu (jeśli failed) z możliwością ponownego przetworzenia lub usunięcia.
- main.php: w dropdownie „Finanse bieżące" dodać `<li><a class="dropdown-item" href="<?= site_url('receipts') ?>">Import paragonów</a></li>`.
</action>
<verify>Pełny przepływ w przeglądarce: upload → review → confirm → operacje widoczne w /operations; nav działa</verify>
<done>AC-1, AC-4, AC-5, AC-6</done>
</task>
</tasks>
<boundaries>
## Do Not Change
- Istniejąca logika `OperationModel` / `CategoryModel` / kontrolera Operations — moduł tylko konsumuje.
- Wartość `OPEN_AI_API` w `.env` (tylko odczyt; ewentualnie dopisać `openai.model`).
- Schemat i logika modułu inwestycji (`inv_*`) — brak styku.
- Auth/flow logowania.
## Scope Limits
- Brak automatycznego księgowania bez potwierdzenia użytkownika (wymóg twardy).
- Brak lokalnej rasteryzacji PDF (idzie do OpenAI file input).
- Brak edycji kwot/pozycji poza wyborem kategorii i pominięciem pozycji (MVP; edycja kwot — ewentualnie później).
- Brak kolejkowania/async — parsowanie synchroniczne w request (paragon = mały wolumen).
- Brak pełnej indeksacji codebase-memory-mcp (odłożone).
</boundaries>
<verification>
- [ ] `php spark migrate` zakłada 3 tabele + kolumnę `operations.receipt_id` (Task 1).
- [ ] Upload jpg/png/pdf/json działa; niedozwolony typ odrzucony (AC-1).
- [ ] ReceiptParser zwraca pozycje z gpt-4o-mini; błąd API → status 'failed', brak operacji (AC-2).
- [ ] Nauczone mapowanie ma priorytet nad AI i tylko podpowiada (AC-3).
- [ ] Review grupuje po kategorii z sumami; edycja/pominięcie działa (AC-4).
- [ ] Confirm tworzy 1 operację/kategoria (suma) + uczy mapowań; podwójne księgowanie zablokowane (AC-5).
- [ ] Historia + podgląd skanu tylko dla zalogowanego (AC-6).
- [ ] Quality Radar: format kwoty `$fmt` spójny; zapis operacji tylko przez OperationModel (bez duplikacji walidacji).
</verification>
<success_criteria>
- [ ] Wszystkie AC-1..AC-6 przechodzą (weryfikacja manualna w przeglądarce).
- [ ] Brak drugiego źródła prawdy dla operacji (użyty OperationModel).
- [ ] `.env`: klucz odczytywany, model konfigurowalny; sekret nie trafia do repo/logów.
- [ ] Impact map / quality risks zaktualizowane w UNIFY.
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260706-2046-import-paragonow/SUMMARY.md`
</output>
@@ -0,0 +1,91 @@
---
plan_id: 20260706-2046-import-paragonow
title: Półautomatyczny import paragonów (AI parsowanie + uczone podpowiedzi kategorii)
completed: 2026-07-06T22:30
storage: plan-first
quality_radar: degraded
---
# Summary: Półautomatyczny import paragonów
## Objective
Umożliwić wgranie paragonu (jpg/png, pdf, json), automatyczne odczytanie pozycji przez AI (OpenAI),
dopasowanie ich do kategorii wydatków z uczonymi podpowiedziami, przegląd/korektę przez użytkownika
i zaksięgowanie jako operacje pogrupowane po kategorii (1 operacja `expense` / kategoria). Nic nie trafia
do `operations` bez zatwierdzenia; podpowiedzi kategorii nigdy nie księgują się automatycznie.
## What Was Built
| Area | Result |
|------|--------|
| Baza | 3 tabele: `receipts`, `receipt_items` (+ `display_name`), `receipt_category_map`; kolumna `operations.receipt_id` (FK SET NULL) |
| Modele | `ReceiptModel` (`withCounts`), `ReceiptItemModel` (`forReceipt`), `ReceiptCategoryMapModel` (`normalize`/`suggest`/`remember`) |
| Parser AI | `App\Libraries\ReceiptParser` — OpenAI **Responses API** (`/v1/responses` + `input_file`), model **o4-mini**, prompt świadomy kolumn paragonu, dwie nazwy (`name` surowa + `display` ładna) |
| Kontroler | `Receipts` (upload→parse→review→confirm→history→file→delete) + trasy `receipts/*` |
| Widoki | `receipts/{new,review,index,show}.php`; przegląd z edytowalnymi nazwami/kwotami i podglądem grupowania na żywo; link „Import paragonów" w nawigacji |
| Kategorie | `CategoryModel::tree()` rozszerzony o `path_label` („Nadrzędna → Podrzędna") — jednoznaczność w podglądzie |
## Files Modified
- `app/Database/Migrations/2026-07-06-2000{01..05}_*.php` - schemat modułu + `display_name`
- `app/Models/{ReceiptModel,ReceiptItemModel,ReceiptCategoryMapModel}.php` - nowe modele
- `app/Models/OperationModel.php` - `allowedFields` += `receipt_id`
- `app/Models/CategoryModel.php` - `path_label` w `tree()`
- `app/Libraries/ReceiptParser.php` - warstwa OpenAI (Responses API, o4-mini, `name`+`display`)
- `app/Controllers/Receipts.php` - pełny przepływ, walidacja bez finfo, transakcja, uczenie na surowej nazwie
- `app/Config/Routes.php` - grupa `receipts/*`
- `app/Views/receipts/*.php`, `app/Views/layout/main.php` - UI + nawigacja
- `.env` - komentarz `openai.model` (domyślnie o4-mini)
## Acceptance Criteria Results
| Criterion | Status | Evidence |
|-----------|--------|----------|
| AC-1 Upload jpg/png/pdf/json | Pass | `/receipts/new` HTTP 200; walidacja rozszerzeń bez finfo; plik w `writable/uploads/receipts/` |
| AC-2 AI parsuje pozycje + kategorie | Pass | Responses API + o4-mini odczytał realny PDF (15 poz., suma = total 68,74); błąd API → status `failed` + `error_msg` |
| AC-3 Nauczone dopasowanie ma priorytet i tylko podpowiada | Pass | `suggest()` (surowa nazwa) > propozycja AI; brak zapisu do `operations` przed `confirm` |
| AC-4 Przegląd grupuje po kategorii + edycja | Pass | Edytowalne nazwa/kwota, podgląd sum na żywo, `path_label` rozróżnia kategorie |
| AC-5 Zatwierdzenie: 1 operacja/kategoria + uczenie | Pass | Grupowanie sum → `OperationModel::insert` z `receipt_id`; `remember(surowa_nazwa, kategoria)`; blokada podwójnego księgowania; transakcja DB |
| AC-6 Historia + podgląd skanu | Pass | `/receipts` HTTP 200; `Receipts::file` serwuje spoza `public/` pod filtrem `auth` |
## Verification Results
| Check | Result | Notes |
|-------|--------|-------|
| `php -l` (wszystkie pliki) | Pass | Zero błędów składni |
| Self-check `normalize`+grupowanie | Pass | assert OK |
| Migracja produkcyjna | Pass | DDL 3 tabel + `receipt_id` + `display_name` zastosowane na zdalnej bazie; wpisy w `migrations` (batch 3 i 4) |
| UAT `/receipts`, `/receipts/new` | Pass | HTTP 200 po zalogowaniu (curl) |
| UAT realny paragon (PDF) | Pass | Użytkownik potwierdził: pozycje/kwoty poprawne (poza wierszem rabatu — korekta ręczna), ładne nazwy OK |
## Quality Radar Results
**Status:** degraded (brak indeksu codebase-memory-mcp; jscpd/ast-grep wyłączone polityką)
- New risks: OCR paragonów niedeterministyczny na wierszach rabatu/gęstym druku — świadomie kryty edytowalnymi kwotami (pół-automat). Koszt tokenów OpenAI per import (o4-mini rozumujący).
- Resolved risks: zapis operacji tylko przez `OperationModel` (bez duplikacji walidacji); klucz OpenAI z `.env`, nie logowany.
- Deferred risks: pełna indeksacja codebase-memory-mcp (`$paul-map-codebase`); normalizacja nazw = dokładne dopasowanie (rozbudować gdy za wąska).
- Raw outputs: `.paul/codebase/{impact_map,quality_risks,tooling_status}.md`
## Deviations
- **Mechanizm AI zmieniony w trakcie**: chat-completions `file_data` NIE dostarczał treści PDF (model halucynował) → przełączono na **Responses API `/v1/responses` + `input_file`**.
- **Model**: plan zakładał gpt-4o-mini; okazał się za słaby na gęsty druk → domyślnie **o4-mini** (rozumujący, najlepszy OCR + rabaty, tańszy niż gpt-4o). Testy: o4-mini suma pozycji = total co do grosza; gpt-4o/4.1 gorzej.
- **Bez finfo**: hostido ma wyłączone `fileinfo` → własna nazwa pliku + MIME z rozszerzenia, walidacja bez `ext_in`.
- **Dwie nazwy pozycji** (dodane na życzenie): `name` (surowa, klucz mapowania) + `display_name` (ładna, do wyświetlania/opisu).
- **PDF = obraz** (brak warstwy tekstowej) → OCR modelu, nie ekstrakcja tekstu.
- Migracje stosowane ręcznie na zdalnej bazie (deploy hostido nie uruchamia `spark migrate`).
## Key Decisions / Patterns
- Przy paragonach liczy się **rozumowanie modelu, nie rozmiar** (o4-mini > gpt-4o/4.1).
- **Pół-automat**: AI proponuje, człowiek weryfikuje/koryguje kwoty i nazwy przed księgowaniem.
- **Uczenie na surowej nazwie z paragonu** (stabilny klucz między wizytami), wyświetlanie po ładnej.
- Jedno źródło prawdy operacji — `OperationModel`; `receipt_id` łączy operacje z paragonem (FK SET NULL).
## Follow-up
- Opcjonalnie: zapamiętywanie ładnej nazwy w `receipt_category_map` (spójny display przy powtórkach).
- Rozważyć limit/licznik kosztów OpenAI, jeśli wolumen wzrośnie.
- `$paul-map-codebase` dla pełnego indeksu przed kolejnym większym planem.
@@ -0,0 +1,168 @@
---
plan_id: 20260712-1647-zapamietywanie-logowania
title: Zapamiętywanie logowania przez 30 dni
storage: plan-first
legacy_phase: null
created: 2026-07-12T16:47:19+02:00
status: planned
type: execute
autonomous: true
delegation: auto
files_modified:
- 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
quality_radar: ok
---
<objective>
## Cel
Umożliwić użytkownikowi zaznaczenie opcji „Zapamiętaj mnie” podczas logowania i utrzymać dostęp przez 30 dni niezależnie na wielu komputerach.
## Powód
Obecna sesja wygasa po 2 godzinach i nie przeżywa zamknięcia przeglądarki. Osobny token dla każdego urządzenia zachowuje równoległe logowania bez współdzielenia jednego identyfikatora sesji.
## Wynik
Checkbox w formularzu, trwałe tokeny przechowywane w bazie wyłącznie jako skróty, automatyczne odtworzenie sesji oraz unieważnienie tokenu bieżącego urządzenia przy wylogowaniu.
</objective>
<context>
## Dokumenty projektu
@.paul/PROJECT.md
@.paul/STATE.md
@.paul/config.md
@.paul/codebase/impact_map.md
@.paul/codebase/quality_risks.md
## Pliki źródłowe
@app/Controllers/Auth.php
@app/Filters/AuthFilter.php
@app/Views/auth/login.php
@app/Config/Routes.php
@app/Config/Cookie.php
@app/Config/Session.php
</context>
<clarifications>
- „Wiele komputerów jednocześnie” oznacza osobny aktywny token na każdym urządzeniu; kolejne logowanie nie usuwa wcześniejszych tokenów.
- Wylogowanie usuwa token tylko z bieżącego urządzenia. Globalne „wyloguj wszędzie” jest poza zakresem.
</clarifications>
<impact_scan>
## Quality Radar
**Status:** ok
**Narzędzia:** codebase-memory-mcp 0.6.1; jscpd i ast-grep wyłączone polityką projektu.
## Dotknięte obszary
- Logowanie: `Auth::login`, `Auth::logout`, `app/Views/auth/login.php`.
- Ochrona tras: `AuthFilter::before` odtwarza zwykłą sesję po poprawnym tokenie.
- Dane: nowa tabela tokenów z wieloma rekordami; konto nadal pochodzi z `.env`.
- Wdrożenie: nowa migracja wymaga ręcznego uruchomienia na hostido.
## Ryzyka duplikacji / wartości hardcoded
- Czas 30 dni ma jedno źródło prawdy w `RememberTokenModel`.
- Token cookie nie może być zapisany jawnie w bazie; tabela przechowuje selektor i hash walidatora.
- Nie wydłużać globalnie `Session::$expiration`, bo nie zapewnia niezależnych urządzeń.
## Jawne odroczenia
- Panel urządzeń i „wyloguj wszędzie” — dodać dopiero na żądanie.
- Harmonogram czyszczenia — przy tej skali wygasłe tokeny wystarczy usuwać podczas użycia mechanizmu.
</impact_scan>
<acceptance_criteria>
## AC-1: Logowanie bez zapamiętania
```gherkin
Given użytkownik nie zaznaczył Zapamiętaj mnie
When poda poprawny email i hasło
Then otrzymuje wyłącznie obecną sesję bez trwałego cookie logowania
```
## AC-2: Logowanie zapamiętane przez 30 dni
```gherkin
Given użytkownik zaznaczył Zapamiętaj mnie
When poda poprawny email i hasło
Then aplikacja zapisuje bezpieczny token i cookie ważne 30 dni
And po utracie zwykłej sesji poprawny token odtwarza sesję i wpuszcza na chronioną trasę
```
## AC-3: Wiele urządzeń
```gherkin
Given użytkownik zaznaczył opcję na dwóch urządzeniach
When oba urządzenia otwierają chronioną trasę bez zwykłej sesji
Then oba niezależne tokeny pozostają poprawne
And wylogowanie na jednym urządzeniu nie unieważnia drugiego
```
## AC-4: Odrzucenie nieważnego tokenu
```gherkin
Given cookie jest zmienione, wygasłe lub nie ma odpowiadającego rekordu
When użytkownik otwiera chronioną trasę
Then cookie i wadliwy rekord są usuwane w możliwym zakresie
And użytkownik trafia na `/login`
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodać bezpieczne tokeny per urządzenie</name>
<files>app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php, app/Models/RememberTokenModel.php</files>
<action>Utworzyć tabelę z unikalnym selektorem, hashem walidatora, `expires_at` i timestampami. W modelu umieścić stałą 30 dni oraz operacje wystawienia tokenu przez `random_bytes`, weryfikacji przez `hash_equals`, usunięcia bieżącego tokenu i wygasłych rekordów. Każde logowanie dopisuje rekord.</action>
<verify>php -l app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php; php -l app/Models/RememberTokenModel.php</verify>
<done>AC-2, AC-3 i AC-4 mają trwały mechanizm danych bez jawnych sekretów w bazie.</done>
</task>
<task type="auto">
<name>Task 2: Podłączyć checkbox, odtworzenie sesji i wylogowanie</name>
<files>app/Controllers/Auth.php, app/Filters/AuthFilter.php, app/Views/auth/login.php</files>
<action>Dodać checkbox `remember`. Po poprawnym logowaniu tworzyć cookie tylko po zaznaczeniu. W filtrze zachować obecną ścieżkę sesji, a przy jej braku zweryfikować cookie i ustawić `logged_in`/`user_email`. Cookie: HttpOnly, SameSite=Lax, 30 dni, Secure w produkcji. `logout()` usuwa token bieżącego cookie, cookie i sesję.</action>
<verify>php -l app/Controllers/Auth.php; php -l app/Filters/AuthFilter.php; php -l app/Views/auth/login.php</verify>
<done>AC-1..AC-4 są obsłużone w przepływie HTTP.</done>
</task>
<task type="auto">
<name>Task 3: Sprawdzić bezpieczeństwo i równoległe urządzenia</name>
<files>tests/unit/RememberTokenModelTest.php</files>
<action>Dodać jeden mały test obejmujący dwa jednocześnie poprawne tokeny, odrzucenie zmienionego walidatora oraz unieważnienie jednego bez wpływu na drugi. Wykonać UAT w dwóch profilach przeglądarki.</action>
<verify>vendor/bin/phpunit tests/unit/RememberTokenModelTest.php; ręcznie: dwa profile przeglądarki zgodnie z AC-1..AC-4</verify>
<done>AC-1..AC-4 mają automatyczną kontrolę tokenów i potwierdzenie przepływu przeglądarkowego.</done>
</task>
</tasks>
<boundaries>
## Nie zmieniać
- Hasło i email nadal pochodzą z `.env`; nie tworzyć systemu wielu kont.
- Nie zmieniać czasu zwykłej sesji ani chronionych tras.
- Nie dotykać modułów finansów, inwestycji i paragonów.
## Ograniczenia zakresu
- Bez nowej zależności, panelu urządzeń, resetu hasła i „wyloguj wszędzie”.
- Bez surowego tokenu lub hasła w bazie i logach.
</boundaries>
<verification>
- [ ] `php -l` przechodzi dla zmienionych plików PHP.
- [ ] `vendor/bin/phpunit tests/unit/RememberTokenModelTest.php` przechodzi w środowisku z `vendor/`.
- [ ] UAT potwierdza zwykłe logowanie, 30-dniowe cookie, dwa profile, wylogowanie jednego profilu i odrzucenie zmienionego cookie.
- [ ] Cookie ma HttpOnly, SameSite=Lax, właściwy termin i Secure na produkcji.
- [ ] Migracja jest zastosowana ręcznie na zdalnej bazie przed wdrożeniem kodu.
- [ ] Ryzyka Quality Radar są obsłużone lub jawnie odroczone.
</verification>
<success_criteria>
- [ ] Wszystkie AC przechodzą.
- [ ] Weryfikacja jest kompletna.
- [ ] Dwa urządzenia pozostają zalogowane niezależnie przez maksymalnie 30 dni.
- [ ] Baza nie zawiera surowych tokenów.
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260712-1647-zapamietywanie-logowania/SUMMARY.md`
</output>
@@ -0,0 +1,75 @@
---
plan_id: 20260712-1647-zapamietywanie-logowania
title: Zapamiętywanie logowania przez 30 dni
completed: 2026-07-12T17:31:28+02:00
storage: plan-first
quality_radar: degraded
---
# Summary: Zapamiętywanie logowania przez 30 dni
## Objective
Dodano opcjonalne logowanie zapamiętane przez 30 dni z niezależnym tokenem dla każdego komputera.
## What Was Built
| Area | Result |
|------|--------|
| Dane | Tabela `remember_tokens` z selektorem, hashem walidatora i terminem ważności; tabela utworzona również na zdalnej bazie. |
| Logowanie | Checkbox `remember` wystawia 30-dniowe cookie tylko po zaznaczeniu. |
| Ochrona tras | `AuthFilter` odtwarza zwykłą sesję z poprawnego tokenu. |
| Wylogowanie | Usuwa token wyłącznie bieżącego urządzenia, cookie i sesję. |
| Test | Dodano jeden test dwóch tokenów, manipulacji walidatorem i selektywnego unieważnienia. |
## Files Modified
- `app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php` — schemat tokenów.
- `app/Models/RememberTokenModel.php` — wystawianie, weryfikacja i usuwanie tokenów.
- `app/Controllers/Auth.php` — wystawianie cookie i selektywne wylogowanie.
- `app/Filters/AuthFilter.php` — odtworzenie sesji.
- `app/Views/auth/login.php` — checkbox „Zapamiętaj mnie”.
- `tests/unit/RememberTokenModelTest.php` — kontrola logiki wielu urządzeń.
## Acceptance Criteria Results
| Criterion | Status | Evidence |
|-----------|--------|----------|
| AC-1 | Pass | Bez checkboxa `Auth::login` nie wystawia trwałego cookie. |
| AC-2 | Partial | Kod, cookie 30 dni i zdalna tabela są gotowe; brak potwierdzonego końcowego UAT po migracji. |
| AC-3 | Partial | Model dopisuje niezależne rekordy, a logout usuwa bieżący selektor; test nie został uruchomiony lokalnie. |
| AC-4 | Partial | Filtr odrzuca zły/wygasły token i usuwa cookie; test nie został uruchomiony lokalnie. |
## Verification Results
| Check | Result | Notes |
|-------|--------|-------|
| `php -l` dla 6 plików | Pass | Brak błędów składni. |
| `vendor/bin/phpunit tests/unit/RememberTokenModelTest.php` | Skipped | Lokalnie brak `vendor/` i `vendor/bin/phpunit`. |
| Zdalny `remember_tokens` | Pass | Potwierdzono 6 kolumn i 3 indeksy. |
| UAT w dwóch profilach | Skipped | Niepotwierdzony przez użytkownika po aktualizacji bazy. |
## Quality Radar Results
**Status:** degraded
- New risks: wdrożenie kodu przed migracją powoduje błąd DB przy użyciu tokenu.
- Resolved risks: zdalna tabela została utworzona; surowy walidator nie trafia do bazy.
- Deferred risks: test PHPUnit i UAT dwóch profili — środowisko lokalne nie ma `vendor/`, UAT wymaga potwierdzenia przeglądarkowego.
- Raw outputs: `.paul/codebase/radar/codebase-memory-post-apply.txt`.
## Deviations
- PHPUnit nie został uruchomiony lokalnie; wykonano lint pliku testowego.
- UAT w dwóch profilach nie został potwierdzony przed UNIFY.
- Legacy `ROADMAP.md` pominięto w trybie plan-first. Commit fazowy pominięto zgodnie z `preferences.auto_commit: false` oraz z powodu licznych niezwiązanych zmian w working tree.
## Key Decisions / Patterns
- Osobny token per urządzenie zamiast wydłużania globalnej sesji.
- W bazie przechowywany jest wyłącznie hash walidatora.
- Wylogowanie nie usuwa tokenów innych urządzeń.
## Follow-up
- Jednorazowo potwierdzić logowanie w dwóch profilach, zamknięcie sesji i wylogowanie tylko jednego profilu.
@@ -0,0 +1 @@
[ 247ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ https://finanse.projectpro.pl/login:0
@@ -0,0 +1,13 @@
- generic [ref=e1]:
- main [ref=e2]:
- generic [ref=e6]:
- heading "finansePRO" [level=1] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: Email
- textbox [active] [ref=e11]
- generic [ref=e12]:
- generic [ref=e13]: Hasło
- textbox [ref=e14]
- button "Zaloguj" [ref=e15] [cursor=pointer]
- img
@@ -0,0 +1,54 @@
- generic [active] [ref=f1e1]:
- navigation [ref=f1e2]:
- generic [ref=f1e3]:
- link "finansePRO" [ref=f1e4] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/dashboard
- generic [ref=f1e5]:
- list [ref=f1e6]:
- listitem [ref=f1e7]:
- link "Pulpit" [ref=f1e8] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/dashboard
- listitem [ref=f1e9]:
- button "Finanse bieżące" [ref=f1e10] [cursor=pointer]
- listitem [ref=f1e11]:
- button "Inwestycje" [ref=f1e12] [cursor=pointer]
- generic [ref=f1e13]: jacek.pyziak@project-pro.pl
- link "Wyloguj" [ref=f1e14] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/logout
- main [ref=f1e15]:
- heading "Pulpit" [level=1] [ref=f1e16]
- generic [ref=f1e18]:
- generic [ref=f1e19]: Majątek razem (saldo bieżące + wartość inwestycji)
- generic [ref=f1e20]: 12 329,83
- generic [ref=f1e21]:
- generic [ref=f1e24]:
- generic [ref=f1e25]:
- heading "Finanse bieżące (rok 2026)" [level=2] [ref=f1e26]
- link "Szczegóły →" [ref=f1e27] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/finances
- generic [ref=f1e28]:
- generic [ref=f1e29]:
- generic [ref=f1e30]: Przychody
- generic [ref=f1e31]: 0,00
- generic [ref=f1e32]:
- generic [ref=f1e33]: Wydatki
- generic [ref=f1e34]: 3 233,43
- generic [ref=f1e35]:
- generic [ref=f1e36]: Saldo
- generic [ref=f1e37]: "-3 233,43 zł"
- generic [ref=f1e40]:
- generic [ref=f1e41]:
- heading "Inwestycje" [level=2] [ref=f1e42]
- link "Szczegóły →" [ref=f1e43] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/investments
- generic [ref=f1e44]:
- generic [ref=f1e45]:
- generic [ref=f1e46]: Wpłacono netto
- generic [ref=f1e47]: 14 300,00
- generic [ref=f1e48]:
- generic [ref=f1e49]: Wartość
- generic [ref=f1e50]: 15 563,26
- generic [ref=f1e51]:
- generic [ref=f1e52]: Zarobek
- generic [ref=f1e53]: 1 263,26
- img
@@ -0,0 +1,502 @@
- generic [active] [ref=f2e1]:
- navigation [ref=f2e2]:
- generic [ref=f2e3]:
- link "finansePRO" [ref=f2e4] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/dashboard
- generic [ref=f2e5]:
- list [ref=f2e6]:
- listitem [ref=f2e7]:
- link "Pulpit" [ref=f2e8] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/dashboard
- listitem [ref=f2e9]:
- button "Finanse bieżące" [ref=f2e10] [cursor=pointer]
- listitem [ref=f2e11]:
- button "Inwestycje" [ref=f2e12] [cursor=pointer]
- generic [ref=f2e13]: jacek.pyziak@project-pro.pl
- link "Wyloguj" [ref=f2e14] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/logout
- main [ref=f2e15]:
- generic [ref=f2e16]:
- heading "Operacje" [level=1] [ref=f2e17]
- link "+ Nowa operacja" [ref=f2e18] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/new
- generic [ref=f2e21]:
- generic [ref=f2e22]:
- generic [ref=f2e23]: Od
- textbox [ref=f2e24]
- generic [ref=f2e25]:
- generic [ref=f2e26]: Do
- textbox [ref=f2e27]
- generic [ref=f2e28]:
- generic [ref=f2e29]: Kategoria
- combobox [ref=f2e30]:
- option "— wszystkie —" [selected]
- option "Inne wydatki"
- option "Inwestycje"
- option "Mieszkanie"
- option "Nauka"
- option "Podróże"
- option "Jedzenie"
- option "Paliwo"
- option "Pamiątki"
- option "Parking"
- option "Rozrywka"
- option "Raty"
- option "Rozrywka"
- option "Transport"
- option "Wynagrodzenie"
- option "Premia"
- option "Zasadniczne"
- option "Zakupy"
- option "Chemia gospodarcza"
- option "Fastfood"
- option "Jedzenie"
- option "Słodycze"
- option "Zdrowie"
- generic [ref=f2e31]:
- generic [ref=f2e32]: Typ
- combobox [ref=f2e33]:
- option "— oba —" [selected]
- option "Przychód"
- option "Wydatek"
- button "Filtruj" [ref=f2e35] [cursor=pointer]
- generic [ref=f2e36]:
- generic [ref=f2e39]:
- generic [ref=f2e40]: Przychody
- generic [ref=f2e41]: 0,00
- generic [ref=f2e44]:
- generic [ref=f2e45]: Wydatki
- generic [ref=f2e46]: 3 233,43
- generic [ref=f2e49]:
- generic [ref=f2e50]: Saldo
- generic [ref=f2e51]: "-3 233,43 zł"
- table [ref=f2e54]:
- rowgroup [ref=f2e55]:
- row "Data Kategoria Opis Kwota Akcje" [ref=f2e56]:
- columnheader "Data" [ref=f2e57]
- columnheader "Kategoria" [ref=f2e58]
- columnheader "Opis" [ref=f2e59]
- columnheader "Kwota" [ref=f2e60]
- columnheader "Akcje" [ref=f2e61]
- rowgroup [ref=f2e62]:
- 'row "2026-07-06 Zakupy → Jedzenie Biedronka: Jabłka polskie Gala luz, Jogurt pitny truskawkowy 400 g, Jogurt pitny brzoskwiniowy 400 g, Mleko 3,2% 1 l, Winogrona jasne bezpestkowe luz, Cebula żółta luz, Gofry Pano 250 g, Marchew luz, Sok jabłkowy Vital Fresh 1 l, Dzik Zero Orange, Banany 67,74 zł Edytuj Usuń" [ref=f2e63]':
- cell "2026-07-06" [ref=f2e64]
- cell "Zakupy → Jedzenie" [ref=f2e65]
- 'cell "Biedronka: Jabłka polskie Gala luz, Jogurt pitny truskawkowy 400 g, Jogurt pitny brzoskwiniowy 400 g, Mleko 3,2% 1 l, Winogrona jasne bezpestkowe luz, Cebula żółta luz, Gofry Pano 250 g, Marchew luz, Sok jabłkowy Vital Fresh 1 l, Dzik Zero Orange, Banany" [ref=f2e66]'
- cell "67,74 zł" [ref=f2e67]
- cell "Edytuj Usuń" [ref=f2e68]:
- link "Edytuj" [ref=f2e69] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/47/edit
- link "Usuń" [ref=f2e70] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/47/delete
- row "2026-07-06 Inwestycje ETF 300,00 zł Edytuj Usuń" [ref=f2e71]:
- cell "2026-07-06" [ref=f2e72]
- cell "Inwestycje" [ref=f2e73]
- cell "ETF" [ref=f2e74]
- cell "300,00 zł" [ref=f2e75]
- cell "Edytuj Usuń" [ref=f2e76]:
- link "Edytuj" [ref=f2e77] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/46/edit
- link "Usuń" [ref=f2e78] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/46/delete
- row "2026-07-06 Nauka Indigo Asia 399,00 zł Edytuj Usuń" [ref=f2e79]:
- cell "2026-07-06" [ref=f2e80]
- cell "Nauka" [ref=f2e81]
- cell "Indigo Asia" [ref=f2e82]
- cell "399,00 zł" [ref=f2e83]
- cell "Edytuj Usuń" [ref=f2e84]:
- link "Edytuj" [ref=f2e85] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/44/edit
- link "Usuń" [ref=f2e86] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/44/delete
- row "2026-07-06 Raty 8,71 zł Edytuj Usuń" [ref=f2e87]:
- cell "2026-07-06" [ref=f2e88]
- cell "Raty" [ref=f2e89]
- cell [ref=f2e90]
- cell "8,71 zł" [ref=f2e91]
- cell "Edytuj Usuń" [ref=f2e92]:
- link "Edytuj" [ref=f2e93] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/43/edit
- link "Usuń" [ref=f2e94] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/43/delete
- row "2026-07-06 Zakupy 20,96 zł Edytuj Usuń" [ref=f2e95]:
- cell "2026-07-06" [ref=f2e96]
- cell "Zakupy" [ref=f2e97]
- cell [ref=f2e98]
- cell "20,96 zł" [ref=f2e99]
- cell "Edytuj Usuń" [ref=f2e100]:
- link "Edytuj" [ref=f2e101] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/42/edit
- link "Usuń" [ref=f2e102] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/42/delete
- row "2026-07-06 Raty 129,90 zł Edytuj Usuń" [ref=f2e103]:
- cell "2026-07-06" [ref=f2e104]
- cell "Raty" [ref=f2e105]
- cell [ref=f2e106]
- cell "129,90 zł" [ref=f2e107]
- cell "Edytuj Usuń" [ref=f2e108]:
- link "Edytuj" [ref=f2e109] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/41/edit
- link "Usuń" [ref=f2e110] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/41/delete
- row "2026-07-06 Inwestycje Obligacje 300,00 zł Edytuj Usuń" [ref=f2e111]:
- cell "2026-07-06" [ref=f2e112]
- cell "Inwestycje" [ref=f2e113]
- cell "Obligacje" [ref=f2e114]
- cell "300,00 zł" [ref=f2e115]
- cell "Edytuj Usuń" [ref=f2e116]:
- link "Edytuj" [ref=f2e117] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/40/edit
- link "Usuń" [ref=f2e118] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/40/delete
- row "2026-07-06 Podróże → Jedzenie 75,60 zł Edytuj Usuń" [ref=f2e119]:
- cell "2026-07-06" [ref=f2e120]
- cell "Podróże → Jedzenie" [ref=f2e121]
- cell [ref=f2e122]
- cell "75,60 zł" [ref=f2e123]
- cell "Edytuj Usuń" [ref=f2e124]:
- link "Edytuj" [ref=f2e125] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/39/edit
- link "Usuń" [ref=f2e126] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/39/delete
- row "2026-07-06 Podróże → Jedzenie 44,87 zł Edytuj Usuń" [ref=f2e127]:
- cell "2026-07-06" [ref=f2e128]
- cell "Podróże → Jedzenie" [ref=f2e129]
- cell [ref=f2e130]
- cell "44,87 zł" [ref=f2e131]
- cell "Edytuj Usuń" [ref=f2e132]:
- link "Edytuj" [ref=f2e133] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/38/edit
- link "Usuń" [ref=f2e134] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/38/delete
- row "2026-07-06 Podróże → Paliwo 183,62 zł Edytuj Usuń" [ref=f2e135]:
- cell "2026-07-06" [ref=f2e136]
- cell "Podróże → Paliwo" [ref=f2e137]
- cell [ref=f2e138]
- cell "183,62 zł" [ref=f2e139]
- cell "Edytuj Usuń" [ref=f2e140]:
- link "Edytuj" [ref=f2e141] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/37/edit
- link "Usuń" [ref=f2e142] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/37/delete
- row "2026-07-06 Podróże → Jedzenie 44,00 zł Edytuj Usuń" [ref=f2e143]:
- cell "2026-07-06" [ref=f2e144]
- cell "Podróże → Jedzenie" [ref=f2e145]
- cell [ref=f2e146]
- cell "44,00 zł" [ref=f2e147]
- cell "Edytuj Usuń" [ref=f2e148]:
- link "Edytuj" [ref=f2e149] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/36/edit
- link "Usuń" [ref=f2e150] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/36/delete
- row "2026-07-06 Podróże → Jedzenie 50,00 zł Edytuj Usuń" [ref=f2e151]:
- cell "2026-07-06" [ref=f2e152]
- cell "Podróże → Jedzenie" [ref=f2e153]
- cell [ref=f2e154]
- cell "50,00 zł" [ref=f2e155]
- cell "Edytuj Usuń" [ref=f2e156]:
- link "Edytuj" [ref=f2e157] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/35/edit
- link "Usuń" [ref=f2e158] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/35/delete
- row "2026-07-06 Podróże → Rozrywka 12,00 zł Edytuj Usuń" [ref=f2e159]:
- cell "2026-07-06" [ref=f2e160]
- cell "Podróże → Rozrywka" [ref=f2e161]
- cell [ref=f2e162]
- cell "12,00 zł" [ref=f2e163]
- cell "Edytuj Usuń" [ref=f2e164]:
- link "Edytuj" [ref=f2e165] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/34/edit
- link "Usuń" [ref=f2e166] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/34/delete
- row "2026-07-06 Podróże → Rozrywka 20,00 zł Edytuj Usuń" [ref=f2e167]:
- cell "2026-07-06" [ref=f2e168]
- cell "Podróże → Rozrywka" [ref=f2e169]
- cell [ref=f2e170]
- cell "20,00 zł" [ref=f2e171]
- cell "Edytuj Usuń" [ref=f2e172]:
- link "Edytuj" [ref=f2e173] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/33/edit
- link "Usuń" [ref=f2e174] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/33/delete
- row "2026-07-06 Podróże → Jedzenie 35,00 zł Edytuj Usuń" [ref=f2e175]:
- cell "2026-07-06" [ref=f2e176]
- cell "Podróże → Jedzenie" [ref=f2e177]
- cell [ref=f2e178]
- cell "35,00 zł" [ref=f2e179]
- cell "Edytuj Usuń" [ref=f2e180]:
- link "Edytuj" [ref=f2e181] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/32/edit
- link "Usuń" [ref=f2e182] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/32/delete
- row "2026-07-06 Podróże → Jedzenie 34,00 zł Edytuj Usuń" [ref=f2e183]:
- cell "2026-07-06" [ref=f2e184]
- cell "Podróże → Jedzenie" [ref=f2e185]
- cell [ref=f2e186]
- cell "34,00 zł" [ref=f2e187]
- cell "Edytuj Usuń" [ref=f2e188]:
- link "Edytuj" [ref=f2e189] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/31/edit
- link "Usuń" [ref=f2e190] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/31/delete
- row "2026-07-06 Podróże → Pamiątki 10,00 zł Edytuj Usuń" [ref=f2e191]:
- cell "2026-07-06" [ref=f2e192]
- cell "Podróże → Pamiątki" [ref=f2e193]
- cell [ref=f2e194]
- cell "10,00 zł" [ref=f2e195]
- cell "Edytuj Usuń" [ref=f2e196]:
- link "Edytuj" [ref=f2e197] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/30/edit
- link "Usuń" [ref=f2e198] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/30/delete
- row "2026-07-06 Podróże → Pamiątki 5,00 zł Edytuj Usuń" [ref=f2e199]:
- cell "2026-07-06" [ref=f2e200]
- cell "Podróże → Pamiątki" [ref=f2e201]
- cell [ref=f2e202]
- cell "5,00 zł" [ref=f2e203]
- cell "Edytuj Usuń" [ref=f2e204]:
- link "Edytuj" [ref=f2e205] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/29/edit
- link "Usuń" [ref=f2e206] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/29/delete
- row "2026-07-06 Podróże → Jedzenie 31,42 zł Edytuj Usuń" [ref=f2e207]:
- cell "2026-07-06" [ref=f2e208]
- cell "Podróże → Jedzenie" [ref=f2e209]
- cell [ref=f2e210]
- cell "31,42 zł" [ref=f2e211]
- cell "Edytuj Usuń" [ref=f2e212]:
- link "Edytuj" [ref=f2e213] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/28/edit
- link "Usuń" [ref=f2e214] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/28/delete
- row "2026-07-06 Podróże → Jedzenie 21,98 zł Edytuj Usuń" [ref=f2e215]:
- cell "2026-07-06" [ref=f2e216]
- cell "Podróże → Jedzenie" [ref=f2e217]
- cell [ref=f2e218]
- cell "21,98 zł" [ref=f2e219]
- cell "Edytuj Usuń" [ref=f2e220]:
- link "Edytuj" [ref=f2e221] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/27/edit
- link "Usuń" [ref=f2e222] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/27/delete
- row "2026-07-06 Podróże → Parking 20,00 zł Edytuj Usuń" [ref=f2e223]:
- cell "2026-07-06" [ref=f2e224]
- cell "Podróże → Parking" [ref=f2e225]
- cell [ref=f2e226]
- cell "20,00 zł" [ref=f2e227]
- cell "Edytuj Usuń" [ref=f2e228]:
- link "Edytuj" [ref=f2e229] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/26/edit
- link "Usuń" [ref=f2e230] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/26/delete
- row "2026-07-06 Podróże → Jedzenie 56,92 zł Edytuj Usuń" [ref=f2e231]:
- cell "2026-07-06" [ref=f2e232]
- cell "Podróże → Jedzenie" [ref=f2e233]
- cell [ref=f2e234]
- cell "56,92 zł" [ref=f2e235]
- cell "Edytuj Usuń" [ref=f2e236]:
- link "Edytuj" [ref=f2e237] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/25/edit
- link "Usuń" [ref=f2e238] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/25/delete
- row "2026-07-06 Podróże → Jedzenie 5,00 zł Edytuj Usuń" [ref=f2e239]:
- cell "2026-07-06" [ref=f2e240]
- cell "Podróże → Jedzenie" [ref=f2e241]
- cell [ref=f2e242]
- cell "5,00 zł" [ref=f2e243]
- cell "Edytuj Usuń" [ref=f2e244]:
- link "Edytuj" [ref=f2e245] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/24/edit
- link "Usuń" [ref=f2e246] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/24/delete
- row "2026-07-06 Podróże → Rozrywka 616,00 zł Edytuj Usuń" [ref=f2e247]:
- cell "2026-07-06" [ref=f2e248]
- cell "Podróże → Rozrywka" [ref=f2e249]
- cell [ref=f2e250]
- cell "616,00 zł" [ref=f2e251]
- cell "Edytuj Usuń" [ref=f2e252]:
- link "Edytuj" [ref=f2e253] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/23/edit
- link "Usuń" [ref=f2e254] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/23/delete
- row "2026-07-06 Podróże → Pamiątki 31,00 zł Edytuj Usuń" [ref=f2e255]:
- cell "2026-07-06" [ref=f2e256]
- cell "Podróże → Pamiątki" [ref=f2e257]
- cell [ref=f2e258]
- cell "31,00 zł" [ref=f2e259]
- cell "Edytuj Usuń" [ref=f2e260]:
- link "Edytuj" [ref=f2e261] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/22/edit
- link "Usuń" [ref=f2e262] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/22/delete
- row "2026-07-06 Podróże → Jedzenie 12,90 zł Edytuj Usuń" [ref=f2e263]:
- cell "2026-07-06" [ref=f2e264]
- cell "Podróże → Jedzenie" [ref=f2e265]
- cell [ref=f2e266]
- cell "12,90 zł" [ref=f2e267]
- cell "Edytuj Usuń" [ref=f2e268]:
- link "Edytuj" [ref=f2e269] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/21/edit
- link "Usuń" [ref=f2e270] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/21/delete
- row "2026-07-06 Podróże → Jedzenie 16,37 zł Edytuj Usuń" [ref=f2e271]:
- cell "2026-07-06" [ref=f2e272]
- cell "Podróże → Jedzenie" [ref=f2e273]
- cell [ref=f2e274]
- cell "16,37 zł" [ref=f2e275]
- cell "Edytuj Usuń" [ref=f2e276]:
- link "Edytuj" [ref=f2e277] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/20/edit
- link "Usuń" [ref=f2e278] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/20/delete
- row "2026-07-06 Podróże → Jedzenie 11,50 zł Edytuj Usuń" [ref=f2e279]:
- cell "2026-07-06" [ref=f2e280]
- cell "Podróże → Jedzenie" [ref=f2e281]
- cell [ref=f2e282]
- cell "11,50 zł" [ref=f2e283]
- cell "Edytuj Usuń" [ref=f2e284]:
- link "Edytuj" [ref=f2e285] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/19/edit
- link "Usuń" [ref=f2e286] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/19/delete
- row "2026-07-06 Podróże → Jedzenie 42,47 zł Edytuj Usuń" [ref=f2e287]:
- cell "2026-07-06" [ref=f2e288]
- cell "Podróże → Jedzenie" [ref=f2e289]
- cell [ref=f2e290]
- cell "42,47 zł" [ref=f2e291]
- cell "Edytuj Usuń" [ref=f2e292]:
- link "Edytuj" [ref=f2e293] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/18/edit
- link "Usuń" [ref=f2e294] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/18/delete
- row "2026-07-06 Podróże → Jedzenie 146,00 zł Edytuj Usuń" [ref=f2e295]:
- cell "2026-07-06" [ref=f2e296]
- cell "Podróże → Jedzenie" [ref=f2e297]
- cell [ref=f2e298]
- cell "146,00 zł" [ref=f2e299]
- cell "Edytuj Usuń" [ref=f2e300]:
- link "Edytuj" [ref=f2e301] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/17/edit
- link "Usuń" [ref=f2e302] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/17/delete
- row "2026-07-06 Podróże → Jedzenie 10,19 zł Edytuj Usuń" [ref=f2e303]:
- cell "2026-07-06" [ref=f2e304]
- cell "Podróże → Jedzenie" [ref=f2e305]
- cell [ref=f2e306]
- cell "10,19 zł" [ref=f2e307]
- cell "Edytuj Usuń" [ref=f2e308]:
- link "Edytuj" [ref=f2e309] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/16/edit
- link "Usuń" [ref=f2e310] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/16/delete
- row "2026-07-06 Podróże → Jedzenie 76,80 zł Edytuj Usuń" [ref=f2e311]:
- cell "2026-07-06" [ref=f2e312]
- cell "Podróże → Jedzenie" [ref=f2e313]
- cell [ref=f2e314]
- cell "76,80 zł" [ref=f2e315]
- cell "Edytuj Usuń" [ref=f2e316]:
- link "Edytuj" [ref=f2e317] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/15/edit
- link "Usuń" [ref=f2e318] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/15/delete
- row "2026-07-06 Podróże → Paliwo 84,21 zł Edytuj Usuń" [ref=f2e319]:
- cell "2026-07-06" [ref=f2e320]
- cell "Podróże → Paliwo" [ref=f2e321]
- cell [ref=f2e322]
- cell "84,21 zł" [ref=f2e323]
- cell "Edytuj Usuń" [ref=f2e324]:
- link "Edytuj" [ref=f2e325] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/14/edit
- link "Usuń" [ref=f2e326] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/14/delete
- row "2026-07-06 Podróże → Jedzenie 9,49 zł Edytuj Usuń" [ref=f2e327]:
- cell "2026-07-06" [ref=f2e328]
- cell "Podróże → Jedzenie" [ref=f2e329]
- cell [ref=f2e330]
- cell "9,49 zł" [ref=f2e331]
- cell "Edytuj Usuń" [ref=f2e332]:
- link "Edytuj" [ref=f2e333] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/13/edit
- link "Usuń" [ref=f2e334] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/13/delete
- row "2026-07-06 Podróże → Jedzenie 9,49 zł Edytuj Usuń" [ref=f2e335]:
- cell "2026-07-06" [ref=f2e336]
- cell "Podróże → Jedzenie" [ref=f2e337]
- cell [ref=f2e338]
- cell "9,49 zł" [ref=f2e339]
- cell "Edytuj Usuń" [ref=f2e340]:
- link "Edytuj" [ref=f2e341] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/12/edit
- link "Usuń" [ref=f2e342] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/12/delete
- row "2026-07-06 Podróże → Jedzenie 7,19 zł Edytuj Usuń" [ref=f2e343]:
- cell "2026-07-06" [ref=f2e344]
- cell "Podróże → Jedzenie" [ref=f2e345]
- cell [ref=f2e346]
- cell "7,19 zł" [ref=f2e347]
- cell "Edytuj Usuń" [ref=f2e348]:
- link "Edytuj" [ref=f2e349] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/11/edit
- link "Usuń" [ref=f2e350] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/11/delete
- row "2026-07-06 Podróże → Jedzenie 22,87 zł Edytuj Usuń" [ref=f2e351]:
- cell "2026-07-06" [ref=f2e352]
- cell "Podróże → Jedzenie" [ref=f2e353]
- cell [ref=f2e354]
- cell "22,87 zł" [ref=f2e355]
- cell "Edytuj Usuń" [ref=f2e356]:
- link "Edytuj" [ref=f2e357] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/10/edit
- link "Usuń" [ref=f2e358] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/10/delete
- row "2026-07-06 Podróże → Jedzenie 31,00 zł Edytuj Usuń" [ref=f2e359]:
- cell "2026-07-06" [ref=f2e360]
- cell "Podróże → Jedzenie" [ref=f2e361]
- cell [ref=f2e362]
- cell "31,00 zł" [ref=f2e363]
- cell "Edytuj Usuń" [ref=f2e364]:
- link "Edytuj" [ref=f2e365] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/9/edit
- link "Usuń" [ref=f2e366] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/9/delete
- row "2026-07-06 Podróże → Parking 28,60 zł Edytuj Usuń" [ref=f2e367]:
- cell "2026-07-06" [ref=f2e368]
- cell "Podróże → Parking" [ref=f2e369]
- cell [ref=f2e370]
- cell "28,60 zł" [ref=f2e371]
- cell "Edytuj Usuń" [ref=f2e372]:
- link "Edytuj" [ref=f2e373] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/8/edit
- link "Usuń" [ref=f2e374] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/8/delete
- row "2026-07-06 Zakupy 127,69 zł Edytuj Usuń" [ref=f2e375]:
- cell "2026-07-06" [ref=f2e376]
- cell "Zakupy" [ref=f2e377]
- cell [ref=f2e378]
- cell "127,69 zł" [ref=f2e379]
- cell "Edytuj Usuń" [ref=f2e380]:
- link "Edytuj" [ref=f2e381] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/3/edit
- link "Usuń" [ref=f2e382] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/3/delete
- row "2026-07-06 Zakupy 65,45 zł Edytuj Usuń" [ref=f2e383]:
- cell "2026-07-06" [ref=f2e384]
- cell "Zakupy" [ref=f2e385]
- cell [ref=f2e386]
- cell "65,45 zł" [ref=f2e387]
- cell "Edytuj Usuń" [ref=f2e388]:
- link "Edytuj" [ref=f2e389] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/2/edit
- link "Usuń" [ref=f2e390] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/2/delete
- row "2026-07-06 Zakupy 8,49 zł Edytuj Usuń" [ref=f2e391]:
- cell "2026-07-06" [ref=f2e392]
- cell "Zakupy" [ref=f2e393]
- cell [ref=f2e394]
- cell "8,49 zł" [ref=f2e395]
- cell "Edytuj Usuń" [ref=f2e396]:
- link "Edytuj" [ref=f2e397] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/1/edit
- link "Usuń" [ref=f2e398] [cursor=pointer]:
- /url: https://finanse.projectpro.pl/operations/1/delete
- img
+460
View File
@@ -0,0 +1,460 @@
{
"ftp://host700513.hostido.net.pl:21@www@finanse.projectpro.pl": {
"public_html": {
"app": {
"Database": {
"Migrations": {
"2026-07-06-100001_CreateCategories.php": {
"type": "-",
"size": 1353,
"lmtime": 0,
"modified": false
},
"2026-07-06-100002_CreateOperations.php": {
"type": "-",
"size": 1377,
"lmtime": 0,
"modified": false
},
"2026-07-06-140001_CreateInvInstruments.php": {
"type": "-",
"size": 812,
"lmtime": 0,
"modified": false
},
"2026-07-06-140002_CreateInvOperations.php": {
"type": "-",
"size": 1423,
"lmtime": 0,
"modified": false
},
"2026-07-06-140003_CreateInvValuations.php": {
"type": "-",
"size": 1200,
"lmtime": 0,
"modified": false
},
"2026-07-06-200001_CreateReceipts.php": {
"type": "-",
"size": 1500,
"lmtime": 1783364101070,
"modified": false
},
"2026-07-06-200002_CreateReceiptItems.php": {
"type": "-",
"size": 1781,
"lmtime": 1783364108533,
"modified": false
},
"2026-07-06-200003_CreateReceiptCategoryMap.php": {
"type": "-",
"size": 1037,
"lmtime": 1783364113574,
"modified": false
},
"2026-07-06-200004_AddReceiptIdToOperations.php": {
"type": "-",
"size": 747,
"lmtime": 1783364116680,
"modified": false
},
".gitkeep": {
"type": "-",
"size": 0,
"lmtime": 0,
"modified": false
},
"2026-07-06-200005_AddDisplayNameToReceiptItems.php": {
"type": "-",
"size": 580,
"lmtime": 1783366821444,
"modified": false
}
}
},
"Models": {
"CategoryModel.php": {
"type": "-",
"size": 3110,
"lmtime": 1783367402037,
"modified": false
},
".gitkeep": {
"type": "-",
"size": 0,
"lmtime": 0,
"modified": false
},
"InstrumentModel.php": {
"type": "-",
"size": 5312,
"lmtime": 0,
"modified": false
},
"InvOperationModel.php": {
"type": "-",
"size": 1578,
"lmtime": 0,
"modified": false
},
"OperationModel.php": {
"type": "-",
"size": 5924,
"lmtime": 1783368871963,
"modified": false
},
"ReceiptCategoryMapModel.php": {
"type": "-",
"size": 1890,
"lmtime": 1783364165741,
"modified": false
},
"ReceiptItemModel.php": {
"type": "-",
"size": 670,
"lmtime": 1783366836342,
"modified": false
},
"ReceiptModel.php": {
"type": "-",
"size": 937,
"lmtime": 1783364152838,
"modified": false
},
"ValuationModel.php": {
"type": "-",
"size": 1345,
"lmtime": 0,
"modified": false
}
},
"Libraries": {
".gitkeep": {
"type": "-",
"size": 0,
"lmtime": 0,
"modified": false
},
"ReceiptParser.php": {
"type": "-",
"size": 7090,
"lmtime": 1783366864902,
"modified": false
}
},
"Controllers": {
"Auth.php": {
"type": "-",
"size": 1314,
"lmtime": 0,
"modified": false
},
"BaseController.php": {
"type": "-",
"size": 1349,
"lmtime": 0,
"modified": false
},
"Categories.php": {
"type": "-",
"size": 2499,
"lmtime": 0,
"modified": false
},
"Dashboard.php": {
"type": "-",
"size": 1948,
"lmtime": 0,
"modified": false
},
"Home.php": {
"type": "-",
"size": 161,
"lmtime": 0,
"modified": false
},
"Instruments.php": {
"type": "-",
"size": 2059,
"lmtime": 0,
"modified": false
},
"Investments.php": {
"type": "-",
"size": 929,
"lmtime": 0,
"modified": false
},
"InvOperations.php": {
"type": "-",
"size": 2940,
"lmtime": 0,
"modified": false
},
"Operations.php": {
"type": "-",
"size": 3467,
"lmtime": 1783368879764,
"modified": false
},
"Receipts.php": {
"type": "-",
"size": 11901,
"lmtime": 1783366894415,
"modified": false
},
"Valuations.php": {
"type": "-",
"size": 2405,
"lmtime": 0,
"modified": false
}
},
"Config": {
"Routes.php": {
"type": "-",
"size": 3384,
"lmtime": 1783364273064,
"modified": false
}
},
"Views": {
"auth": {},
"categories": {},
"dashboard": {},
"errors": {},
"instruments": {},
"investments": {},
"inv_operations": {},
"layout": {
"main.php": {
"type": "-",
"size": 3815,
"lmtime": 1783364283479,
"modified": false
}
},
"operations": {
"form.php": {
"type": "-",
"size": 3663,
"lmtime": 0,
"modified": false
},
"index.php": {
"type": "-",
"size": 7243,
"lmtime": 1783368918877,
"modified": false
}
},
"valuations": {},
"welcome_message.php": {
"type": "-",
"size": 17352,
"lmtime": 0,
"modified": false
},
"receipts": {
"index.php": {
"type": "-",
"size": 2713,
"lmtime": 1783364332599,
"modified": false
},
"new.php": {
"type": "-",
"size": 1165,
"lmtime": 1783364292863,
"modified": false
},
"review.php": {
"type": "-",
"size": 5620,
"lmtime": 1783367409167,
"modified": false
},
"show.php": {
"type": "-",
"size": 4062,
"lmtime": 1783366909680,
"modified": false
}
}
}
},
"builds": {
"type": "-",
"size": 4129,
"lmtime": 0,
"modified": false
},
"cgi-bin": {},
".claude": {},
"composer.json": {
"type": "-",
"size": 1088,
"lmtime": 0,
"modified": false
},
"composer.lock": {
"type": "-",
"size": 77771,
"lmtime": 0,
"modified": false
},
".env": {
"type": "-",
"size": 1008,
"lmtime": 1783366618371,
"modified": false
},
".gitignore": {
"type": "-",
"size": 1858,
"lmtime": 0,
"modified": false
},
".htaccess": {
"type": "-",
"size": 1084,
"lmtime": 0,
"modified": false
},
"LICENSE": {
"type": "-",
"size": 1162,
"lmtime": 0,
"modified": false
},
"operations-before.png": {
"type": "-",
"size": 227909,
"lmtime": 1783368826240,
"modified": false
},
".paul": {
"changelog": {
"2026-07-06.md": {
"type": "-",
"size": 4897,
"lmtime": 1783367953541,
"modified": false
}
},
"codebase": {
"impact_map.md": {
"type": "-",
"size": 3932,
"lmtime": 1783368033490,
"modified": false
},
"quality_risks.md": {
"type": "-",
"size": 2521,
"lmtime": 1783368045743,
"modified": false
},
"radar": {},
"tooling_status.md": {
"type": "-",
"size": 1103,
"lmtime": 1783364442507,
"modified": false
}
},
"config.md": {
"type": "-",
"size": 729,
"lmtime": 0,
"modified": false
},
"handoffs": {},
"plans": {
"20260706-2046-import-paragonow": {
"PLAN.md": {
"type": "-",
"size": 19453,
"lmtime": 1783363706084,
"modified": false
},
"SUMMARY.md": {
"type": "-",
"size": 6214,
"lmtime": 1783367919539,
"modified": false
}
}
},
"PROJECT.md": {
"type": "-",
"size": 645,
"lmtime": 0,
"modified": false
},
"rules": {},
"STATE.md": {
"type": "-",
"size": 2648,
"lmtime": 1783368004681,
"modified": false
}
},
"phpunit.dist.xml": {
"type": "-",
"size": 2422,
"lmtime": 0,
"modified": false
},
".playwright-mcp": {
"console-2026-07-06T20-13-14-268Z.log": {
"type": "-",
"size": 191,
"lmtime": 1783368794531,
"modified": false
},
"page-2026-07-06T20-13-14-565Z.yml": {
"type": "-",
"size": 399,
"lmtime": 1783368794567,
"modified": false
},
"page-2026-07-06T20-13-40-056Z.yml": {
"type": "-",
"size": 2343,
"lmtime": 1783368820058,
"modified": false
},
"page-2026-07-06T20-13-43-467Z.yml": {
"type": "-",
"size": 27212,
"lmtime": 1783368823469,
"modified": false
}
},
"preload.php": {
"type": "-",
"size": 3403,
"lmtime": 0,
"modified": false
},
"public": {},
"README.md": {
"type": "-",
"size": 3034,
"lmtime": 0,
"modified": false
},
"spark": {
"type": "-",
"size": 2668,
"lmtime": 0,
"modified": false
},
"tests": {},
"vendor": {},
"writable": {}
}
},
"$version": 1
}
+10
View File
@@ -23,6 +23,16 @@ $routes->group('', ['filter' => 'auth'], static function (RouteCollection $route
$routes->post('categories/(:num)', 'Categories::update/$1'); $routes->post('categories/(:num)', 'Categories::update/$1');
$routes->get('categories/(:num)/delete', 'Categories::delete/$1'); $routes->get('categories/(:num)/delete', 'Categories::delete/$1');
// Import paragonów (AI parsowanie + uczone podpowiedzi kategorii)
$routes->get('receipts', 'Receipts::index');
$routes->get('receipts/new', 'Receipts::new');
$routes->post('receipts', 'Receipts::create');
$routes->get('receipts/(:num)/review', 'Receipts::review/$1');
$routes->post('receipts/(:num)/confirm', 'Receipts::confirm/$1');
$routes->get('receipts/(:num)/file', 'Receipts::file/$1');
$routes->get('receipts/(:num)/delete', 'Receipts::delete/$1');
$routes->get('receipts/(:num)', 'Receipts::show/$1');
// Operacje // Operacje
$routes->get('operations', 'Operations::index'); $routes->get('operations', 'Operations::index');
$routes->get('operations/new', 'Operations::new'); $routes->get('operations/new', 'Operations::new');
+24 -2
View File
@@ -2,6 +2,8 @@
namespace App\Controllers; namespace App\Controllers;
use App\Models\RememberTokenModel;
/** /**
* Logowanie jednego uzytkownika wprost z .env (user_email + user_password). * Logowanie jednego uzytkownika wprost z .env (user_email + user_password).
* ponytail: jawne haslo z .env, narzedzie jednoosobowe. Podniesc do password_hash, * ponytail: jawne haslo z .env, narzedzie jednoosobowe. Podniesc do password_hash,
@@ -28,7 +30,22 @@ class Auth extends BaseController
'user_email' => $email, 'user_email' => $email,
]); ]);
return redirect()->to('/dashboard'); $response = redirect()->to('/dashboard');
if ($this->request->getPost('remember') === '1') {
$response->setCookie(
'remember_token',
(new RememberTokenModel())->issueToken(),
RememberTokenModel::LIFETIME_SECONDS,
'',
'/',
'',
ENVIRONMENT === 'production',
true,
'Lax'
);
}
return $response;
} }
return redirect()->back()->with('error', 'Nieprawidłowy email lub hasło.'); return redirect()->back()->with('error', 'Nieprawidłowy email lub hasło.');
@@ -39,8 +56,13 @@ class Auth extends BaseController
public function logout() public function logout()
{ {
$token = (string) $this->request->getCookie('remember_token');
if ($token !== '') {
(new RememberTokenModel())->deleteToken($token);
}
session()->destroy(); session()->destroy();
return redirect()->to('/login'); return redirect()->to('/login')->deleteCookie('remember_token', '', '/');
} }
} }
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace App\Controllers;
use App\Libraries\ReceiptParser;
use App\Models\CategoryModel;
use App\Models\OperationModel;
use App\Models\ReceiptCategoryMapModel;
use App\Models\ReceiptItemModel;
use App\Models\ReceiptModel;
use Throwable;
class Receipts extends BaseController
{
private function receipts(): ReceiptModel
{
return model(ReceiptModel::class);
}
private function items(): ReceiptItemModel
{
return model(ReceiptItemModel::class);
}
public function index()
{
return view('receipts/index', [
'title' => 'Import paragonów',
'receipts' => $this->receipts()->withCounts(),
]);
}
public function new()
{
return view('receipts/new', ['title' => 'Nowy import paragonu']);
}
/**
* Upload + parsowanie AI + zapis pozycji z podpowiedziami kategorii.
*/
public function create()
{
// Walidacja bez regul zaleznych od finfo (ext_in/mime_in zgaduja typ przez fileinfo).
$rules = [
'receipt' => 'uploaded[receipt]|max_size[receipt,10240]',
];
if (! $this->validate($rules)) {
return redirect()->back()->with('error', implode(' ', $this->validator->getErrors()));
}
$file = $this->request->getFile('receipt');
if (! $file->isValid()) {
return redirect()->back()->with('error', 'Nieprawidłowy plik.');
}
// Whitelist rozszerzen na surowej nazwie klienta (bez finfo).
$allowed = ['jpg', 'jpeg', 'png', 'pdf', 'json'];
if (! in_array(strtolower($file->getClientExtension()), $allowed, true)) {
return redirect()->back()->with('error', 'Dozwolone formaty: JPG, PNG, PDF, JSON.');
}
$receiptId = null;
$model = $this->receipts();
$duplicate = $model->hasOriginalName($file->getClientName());
try {
// Upewnij sie, ze katalog docelowy istnieje i jest zapisywalny (hostido).
$dir = WRITEPATH . 'uploads/receipts';
if (! is_dir($dir) && ! @mkdir($dir, 0775, true) && ! is_dir($dir)) {
throw new \RuntimeException('Nie mozna utworzyc katalogu na skany: ' . $dir);
}
if (! is_writable($dir)) {
throw new \RuntimeException('Katalog na skany nie jest zapisywalny: ' . $dir);
}
// Losowa nazwa z rozszerzenia klienckiego — bez getRandomName()/finfo (wylaczone na hostingu).
$ext = strtolower($file->getClientExtension() ?: 'bin');
$newName = bin2hex(random_bytes(8)) . '.' . $ext;
$storedRel = $file->store('receipts', $newName);
// Typ MIME po rozszerzeniu (bez finfo).
$mime = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'pdf' => 'application/pdf', 'json' => 'application/json'][$ext] ?? 'application/octet-stream';
$receiptId = $model->insert([
'file_path' => $storedRel,
'original_name' => $file->getClientName(),
'mime' => $mime,
'status' => 'parsing',
], true);
$categories = model(CategoryModel::class)->flatList('expense');
$names = array_column($categories, 'name');
$nameToId = [];
foreach ($categories as $c) {
$nameToId[mb_strtolower($c['name'])] = (int) $c['id'];
}
$parsed = (new ReceiptParser())->parse(WRITEPATH . 'uploads/' . $storedRel, $mime, $names);
$mapModel = model(ReceiptCategoryMapModel::class);
foreach ($parsed['items'] as $it) {
$aiCatId = $nameToId[mb_strtolower((string) $it['category'])] ?? null;
// Priorytet nauczonego mapowania nad propozycja AI (obie tylko sugeruja).
$suggested = $mapModel->suggest($it['name']) ?? $aiCatId;
$this->items()->insert([
'receipt_id' => $receiptId,
'name' => $it['name'],
'display_name' => $it['display'],
'qty' => $it['qty'],
'amount' => $it['amount'],
'suggested_category_id' => $suggested,
]);
}
$model->update($receiptId, [
'merchant' => $parsed['merchant'],
'receipt_date' => $parsed['date'],
'total' => $parsed['total'],
'raw_json' => json_encode($parsed, JSON_UNESCAPED_UNICODE),
'status' => 'parsed',
]);
$redirect = redirect()->to('receipts/' . $receiptId . '/review');
return $duplicate
? $redirect->with('warning', 'Plik o nazwie „' . $file->getClientName() . '” był już wcześniej importowany.')
: $redirect;
} catch (Throwable $e) {
log_message('error', 'Import paragonu: ' . $e->getMessage());
$msg = mb_substr($e->getMessage(), 0, 255);
if ($receiptId !== null) {
$model->update($receiptId, ['status' => 'failed', 'error_msg' => $msg]);
return redirect()->to('receipts/' . $receiptId)
->with('error', 'Import nie powiódł się: ' . $e->getMessage());
}
// Blad przed utworzeniem rekordu (np. zapis pliku) — zapisz slad do odczytu.
$model->insert([
'file_path' => '',
'original_name' => $file->getClientName(),
'mime' => $file->getClientMimeType() ?: 'application/octet-stream',
'status' => 'failed',
'error_msg' => $msg,
]);
return redirect()->back()->with('error', 'Import nie powiódł się: ' . $e->getMessage());
}
}
/**
* Ekran przegladu: pozycje z selectem kategorii, grupowane po kategorii w podgladzie.
*/
public function review($id)
{
$receipt = $this->receipts()->find((int) $id);
if (! $receipt) {
return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.');
}
if ($receipt['status'] === 'imported') {
return redirect()->to('receipts/' . $id)->with('error', 'Ten paragon został już zaksięgowany.');
}
return view('receipts/review', [
'title' => 'Przegląd paragonu',
'receipt' => $receipt,
'items' => $this->items()->forReceipt((int) $id),
'categories' => model(CategoryModel::class)->tree('expense'),
]);
}
/**
* Zatwierdzenie: grupowanie po kategorii -> 1 operacja/kategoria (suma) + uczenie mapowan.
*/
public function confirm($id)
{
$receipt = $this->receipts()->find((int) $id);
if (! $receipt) {
return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.');
}
if ($receipt['status'] === 'imported') {
return redirect()->to('receipts/' . $id)->with('error', 'Ten paragon został już zaksięgowany.');
}
$chosen = (array) $this->request->getPost('chosen_category_id'); // itemId => categoryId
$postDisp = (array) $this->request->getPost('display'); // itemId => poprawiona ladna nazwa
$postAmt = (array) $this->request->getPost('amount'); // itemId => poprawiona kwota
$items = $this->items()->forReceipt((int) $id);
$opModel = model(OperationModel::class);
$mapModel = model(ReceiptCategoryMapModel::class);
// Grupowanie: categoryId => ['sum' => float, 'names' => [...]]
$groups = [];
$db = db_connect();
$db->transStart();
foreach ($items as $it) {
$catId = isset($chosen[$it['id']]) && $chosen[$it['id']] !== '' ? (int) $chosen[$it['id']] : null;
// Ladna nazwa i kwota edytowalne; surowa 'name' (klucz mapowania) pozostaje z paragonu.
$display = isset($postDisp[$it['id']]) ? trim((string) $postDisp[$it['id']]) : (string) $it['display_name'];
$amount = isset($postAmt[$it['id']]) ? round((float) $postAmt[$it['id']], 2) : (float) $it['amount'];
if ($display === '') {
$display = $it['display_name'] ?: $it['name'];
}
$this->items()->update($it['id'], [
'display_name' => $display,
'amount' => number_format($amount, 2, '.', ''),
'chosen_category_id' => $catId,
]);
if ($catId === null) {
continue; // pominieta pozycja — nie ksiegujemy
}
$groups[$catId]['sum'] = ($groups[$catId]['sum'] ?? 0) + $amount;
$groups[$catId]['names'][] = $display;
$mapModel->remember($it['name'], $catId); // uczenie na surowej nazwie z paragonu -> kategoria
}
$date = $receipt['receipt_date'] ?: date('Y-m-d');
$prefix = $receipt['merchant'] ? $receipt['merchant'] . ': ' : 'Paragon: ';
$created = 0;
foreach ($groups as $catId => $g) {
$desc = $prefix . implode(', ', $g['names']);
$opModel->insert([
'date' => $date,
'amount' => number_format($g['sum'], 2, '.', ''),
'type' => 'expense',
'category_id' => $catId,
'receipt_id' => (int) $id,
'description' => mb_substr($desc, 0, 255),
]);
$created++;
}
$this->receipts()->update($id, ['status' => 'imported']);
$db->transComplete();
if ($db->transStatus() === false) {
return redirect()->to('receipts/' . $id)->with('error', 'Nie udało się zaksięgować paragonu (transakcja).');
}
return redirect()->to('receipts/' . $id)
->with('success', 'Zaksięgowano ' . $created . ' operacji z paragonu.');
}
public function show($id)
{
$receipt = $this->receipts()->find((int) $id);
if (! $receipt) {
return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.');
}
$operations = [];
if ($receipt['status'] === 'imported') {
$operations = db_connect()->table('operations o')
->select('o.*, c.name AS category_name')
->join('categories c', 'c.id = o.category_id', 'left')
->where('o.receipt_id', (int) $id)
->orderBy('o.id', 'ASC')->get()->getResultArray();
}
return view('receipts/show', [
'title' => 'Paragon',
'receipt' => $receipt,
'items' => $this->items()->forReceipt((int) $id),
'operations' => $operations,
]);
}
/**
* Serwowanie skanu spoza public/ (tylko pod filtrem auth).
*/
public function file($id)
{
$receipt = $this->receipts()->find((int) $id);
if (! $receipt) {
return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.');
}
$path = WRITEPATH . 'uploads/' . $receipt['file_path'];
if (! is_file($path)) {
return redirect()->to('receipts/' . $id)->with('error', 'Plik skanu nie istnieje.');
}
return $this->response->setHeader('Content-Type', $receipt['mime'])->setBody(file_get_contents($path));
}
public function delete($id)
{
$receipt = $this->receipts()->find((int) $id);
if ($receipt) {
$path = WRITEPATH . 'uploads/' . $receipt['file_path'];
if (is_file($path)) {
@unlink($path);
}
$this->receipts()->delete((int) $id); // CASCADE czysci receipt_items
}
return redirect()->to('receipts')->with('success', 'Paragon usunięty.');
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateReceipts extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'file_path' => ['type' => 'VARCHAR', 'constraint' => 255],
'original_name' => ['type' => 'VARCHAR', 'constraint' => 255],
'mime' => ['type' => 'VARCHAR', 'constraint' => 100],
'merchant' => ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true],
'receipt_date' => ['type' => 'DATE', 'null' => true],
'total' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => true],
'status' => ['type' => 'ENUM', 'constraint' => ['parsing', 'parsed', 'failed', 'imported'], 'default' => 'parsing'],
'raw_json' => ['type' => 'LONGTEXT', 'null' => true],
'error_msg' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('status');
$this->forge->createTable('receipts', true, ['ENGINE' => 'InnoDB']);
}
public function down(): void
{
$this->forge->dropTable('receipts', true);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateReceiptItems extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'receipt_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 255],
'qty' => ['type' => 'DECIMAL', 'constraint' => '10,3', 'null' => true],
'amount' => ['type' => 'DECIMAL', 'constraint' => '12,2'],
'suggested_category_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'chosen_category_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('receipt_id');
// Kasacja paragonu kasuje jego pozycje.
$this->forge->addForeignKey('receipt_id', 'receipts', 'id', 'CASCADE', 'CASCADE');
// Kasacja kategorii zeruje podpowiedz/wybor (nie blokuje).
$this->forge->addForeignKey('suggested_category_id', 'categories', 'id', 'SET NULL', 'SET NULL');
$this->forge->addForeignKey('chosen_category_id', 'categories', 'id', 'SET NULL', 'SET NULL');
$this->forge->createTable('receipt_items', true, ['ENGINE' => 'InnoDB']);
}
public function down(): void
{
$this->forge->dropTable('receipt_items', true);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateReceiptCategoryMap extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'item_name' => ['type' => 'VARCHAR', 'constraint' => 255],
'category_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
// Znormalizowana nazwa pozycji jest kluczem uczenia (jedno mapowanie na nazwe).
$this->forge->addUniqueKey('item_name');
$this->forge->addForeignKey('category_id', 'categories', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('receipt_category_map', true, ['ENGINE' => 'InnoDB']);
}
public function down(): void
{
$this->forge->dropTable('receipt_category_map', true);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddReceiptIdToOperations extends Migration
{
public function up(): void
{
$this->forge->addColumn('operations', [
'receipt_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'category_id'],
]);
// Kasacja paragonu nie kasuje operacji, tylko zrywa powiazanie.
$this->forge->addForeignKey('receipt_id', 'receipts', 'id', 'SET NULL', 'SET NULL', 'operations_receipt_id_fk');
}
public function down(): void
{
$this->forge->dropForeignKey('operations', 'operations_receipt_id_fk');
$this->forge->dropColumn('operations', 'receipt_id');
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddDisplayNameToReceiptItems extends Migration
{
public function up(): void
{
$this->forge->addColumn('receipt_items', [
// Ladna, czytelna nazwa do wyswietlania i opisu operacji (name = surowa z paragonu, klucz mapowania).
'display_name' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true, 'after' => 'name'],
]);
}
public function down(): void
{
$this->forge->dropColumn('receipt_items', 'display_name');
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateRememberTokens extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'selector' => ['type' => 'VARCHAR', 'constraint' => 32],
'validator_hash' => ['type' => 'CHAR', 'constraint' => 64],
'expires_at' => ['type' => 'DATETIME'],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('selector');
$this->forge->addKey('expires_at');
$this->forge->createTable('remember_tokens', true, ['ENGINE' => 'InnoDB']);
}
public function down(): void
{
$this->forge->dropTable('remember_tokens', true);
}
}
+15 -2
View File
@@ -2,6 +2,7 @@
namespace App\Filters; namespace App\Filters;
use App\Models\RememberTokenModel;
use CodeIgniter\Filters\FilterInterface; use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\ResponseInterface;
@@ -13,9 +14,21 @@ class AuthFilter implements FilterInterface
{ {
public function before(RequestInterface $request, $arguments = null) public function before(RequestInterface $request, $arguments = null)
{ {
if (! session()->get('logged_in')) { if (session()->get('logged_in')) {
return redirect()->to('/login'); return;
} }
$token = (string) $request->getCookie('remember_token');
if ($token !== '' && (new RememberTokenModel())->verifyToken($token)) {
session()->set([
'logged_in' => true,
'user_email' => (string) env('user_email'),
]);
return;
}
return redirect()->to('/login')->deleteCookie('remember_token', '', '/');
} }
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Libraries;
use RuntimeException;
/**
* Parsowanie paragonu przez OpenAI Responses API (/v1/responses).
* Wejscie: obraz (input_image), PDF (input_file — OpenAI renderuje strony), JSON/tekst (input_text).
* Wyjscie: merchant, date, total, items[] z proponowana kategoria (nazwa lub null).
*
* UWAGA: chat-completions z file_data NIE dostarcza tresci PDF (model halucynuje).
* Dziala dopiero Responses API + input_file. gpt-4o czyta gesty paragon lepiej niz mini.
* ponytail: jeden POST przez \Config\Services::curlrequest() — bez SDK.
*/
class ReceiptParser
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->apiKey = (string) env('OPEN_AI_API');
// o4-mini (model rozumujacy) czyta gesty paragon fiskalny najdokladniej — lepiej niz gpt-4o/4.1
// i tansze; poprawnie liczy rabaty (Opust). Podmiana przez env('openai.model').
$this->model = (string) (env('openai.model') ?: 'o4-mini');
if ($this->apiKey === '') {
throw new RuntimeException('Brak klucza OPEN_AI_API w .env.');
}
}
/**
* @param string $filePath sciezka do wgranego pliku
* @param string $mime typ MIME pliku
* @param string[] $categories dozwolone nazwy kategorii wydatkow (do dopasowania)
*
* @return array{merchant:?string,date:?string,total:?float,items:array<int,array{name:string,qty:?float,amount:float,category:?string}>}
*/
public function parse(string $filePath, string $mime, array $categories): array
{
$content = file_get_contents($filePath);
if ($content === false) {
throw new RuntimeException('Nie udalo sie odczytac pliku paragonu.');
}
$parts = [['type' => 'input_text', 'text' => $this->prompt($categories)]];
if (str_starts_with($mime, 'image/')) {
$parts[] = ['type' => 'input_image', 'image_url' => 'data:' . $mime . ';base64,' . base64_encode($content)];
} elseif ($mime === 'application/pdf') {
$parts[] = [
'type' => 'input_file',
'filename' => 'paragon.pdf',
'file_data' => 'data:application/pdf;base64,' . base64_encode($content),
];
} else {
// JSON (lub inny tekst) — przekaz tresc do interpretacji.
$parts[] = ['type' => 'input_text', 'text' => "Dane paragonu (surowe):\n" . $content];
}
$payload = [
'model' => $this->model,
'text' => ['format' => ['type' => 'json_object']],
'input' => [['role' => 'user', 'content' => $parts]],
];
$client = \Config\Services::curlrequest(['timeout' => 120]);
$response = $client->post('https://api.openai.com/v1/responses', [
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
'body' => json_encode($payload),
'http_errors' => false,
]);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
if ($status < 200 || $status >= 300) {
throw new RuntimeException('OpenAI API blad (HTTP ' . $status . '): ' . mb_substr($body, 0, 300));
}
$decoded = json_decode($body, true);
$text = $this->extractText($decoded);
if ($text === '') {
throw new RuntimeException('Nieoczekiwana odpowiedz OpenAI (brak tresci).');
}
$parsed = json_decode($text, true);
if (! is_array($parsed)) {
throw new RuntimeException('OpenAI zwrocilo niepoprawny JSON paragonu.');
}
return $this->normalize($parsed);
}
/**
* Wyciaga tekst z odpowiedzi Responses API (output_text lub output[].content[].text).
*/
private function extractText(?array $d): string
{
if (! is_array($d)) {
return '';
}
if (! empty($d['output_text']) && is_string($d['output_text'])) {
return $d['output_text'];
}
$txt = '';
foreach ($d['output'] ?? [] as $o) {
foreach ($o['content'] ?? [] as $c) {
if (isset($c['text']) && is_string($c['text'])) {
$txt .= $c['text'];
}
}
}
return $txt;
}
private function prompt(array $categories): string
{
$list = implode(', ', $categories);
return <<<TXT
To jest polski PARAGON FISKALNY (kolumny tabeli pozycji: Nazwa, PTU, Ilosc, Cena, Wartosc).
Zwroc WYLACZNIE obiekt JSON o strukturze:
{"merchant":string|null,"date":"YYYY-MM-DD"|null,"total":number|null,"items":[{"name":string,"display":string,"qty":number|null,"amount":number,"category":string|null}]}
Zasady:
- "name" = nazwa DOKLADNIE jak na paragonie (skrocona, np. "WinogrJasneBezpLuz") — sluzy do mapowania.
- "display" = ladna, czytelna nazwa produktu po polsku dla czlowieka (np. "Winogrona jasne bezpestkowe").
- "amount" = kwota z kolumny WARTOSC (skrajnie prawa, laczna wartosc pozycji), NIE cena jednostkowa.
- Linia "Opust"/"Rabat" ze znakiem minus: odejmij ja od wartosci poprzedniej pozycji (podaj amount po rabacie).
- Ujmij pozycje z sekcji "Opakowania zwrotne"/kaucja, jesli wystepuja.
- POMIN wiersze podsumowan: Sprzedaz opodatkowana, PTU, Suma PLN, DO ZAPLATY.
- "date" = data zakupu z paragonu. "total" = kwota DO ZAPLATY.
- "category" dobierz TYLKO z tej listy (dokladna nazwa) albo null: {$list}
Nie dodawaj zadnego tekstu poza obiektem JSON.
TXT;
}
/**
* Sanityzacja i rzutowanie typow z odpowiedzi modelu.
*/
private function normalize(array $p): array
{
$items = [];
foreach ($p['items'] ?? [] as $it) {
$name = trim((string) ($it['name'] ?? ''));
if ($name === '') {
continue;
}
$display = trim((string) ($it['display'] ?? ''));
$items[] = [
'name' => $name,
'display' => $display !== '' ? $display : $name,
'qty' => isset($it['qty']) && $it['qty'] !== null ? (float) $it['qty'] : null,
'amount' => round((float) ($it['amount'] ?? 0), 2),
'category' => isset($it['category']) && $it['category'] !== null && $it['category'] !== ''
? (string) $it['category'] : null,
];
}
$date = isset($p['date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $p['date']) ? (string) $p['date'] : null;
return [
'merchant' => isset($p['merchant']) && $p['merchant'] !== '' ? (string) $p['merchant'] : null,
'date' => $date,
'total' => isset($p['total']) && $p['total'] !== null ? round((float) $p['total'], 2) : null,
'items' => $items,
];
}
}
+6 -3
View File
@@ -60,15 +60,17 @@ class CategoryModel extends Model
$indent = "\u{00A0}\u{00A0}\u{00A0}\u{00A0}"; // 4x twarda spacja na poziom $indent = "\u{00A0}\u{00A0}\u{00A0}\u{00A0}"; // 4x twarda spacja na poziom
$out = []; $out = [];
$walk = static function (int $parentId, int $depth) use (&$walk, &$out, $childrenByParent, $indent): void { $walk = static function (int $parentId, int $depth, string $parentPath) use (&$walk, &$out, $childrenByParent, $indent): void {
foreach ($childrenByParent[$parentId] ?? [] as $c) { foreach ($childrenByParent[$parentId] ?? [] as $c) {
$c['depth'] = $depth; $c['depth'] = $depth;
$c['indent_label'] = str_repeat($indent, $depth) . $c['name']; $c['indent_label'] = str_repeat($indent, $depth) . $c['name'];
// Pelna sciezka "Nadrzedna → Podrzedna" — jednoznaczna nawet gdy JS uciasnie wciecia.
$c['path_label'] = $parentPath === '' ? $c['name'] : $parentPath . ' → ' . $c['name'];
$out[] = $c; $out[] = $c;
$walk((int) $c['id'], $depth + 1); $walk((int) $c['id'], $depth + 1, $c['path_label']);
} }
}; };
$walk(0, 0); $walk(0, 0, '');
// Sieroty (np. rodzic odfiltrowany typem) — dopnij na koncu bez wciecia // Sieroty (np. rodzic odfiltrowany typem) — dopnij na koncu bez wciecia
$seen = array_column($out, null, 'id'); $seen = array_column($out, null, 'id');
@@ -76,6 +78,7 @@ class CategoryModel extends Model
if (! isset($seen[$c['id']])) { if (! isset($seen[$c['id']])) {
$c['depth'] = 0; $c['depth'] = 0;
$c['indent_label'] = $c['name']; $c['indent_label'] = $c['name'];
$c['path_label'] = $c['name'];
$out[] = $c; $out[] = $c;
} }
} }
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
* Uczona pamiec dopasowan: znormalizowana nazwa pozycji -> kategoria.
* Sluzy WYLACZNIE do podpowiedzi; nic nie jest ksiegowane automatycznie.
*/
class ReceiptCategoryMapModel extends Model
{
protected $table = 'receipt_category_map';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = ''; // tabela ma tylko updated_at
protected $allowedFields = ['item_name', 'category_id', 'updated_at'];
/**
* Normalizacja nazwy pozycji jako klucz uczenia:
* trim + lowercase + redukcja wielokrotnych bialych znakow.
* ponytail: bez usuwania gramatur/liczb — zaczynamy od dokladnego dopasowania nazwy.
*/
public function normalize(string $name): string
{
$n = mb_strtolower(trim($name));
return preg_replace('/\s+/u', ' ', $n) ?? $n;
}
/**
* Podpowiedziana kategoria dla nazwy pozycji lub null.
*/
public function suggest(string $name): ?int
{
$row = $this->where('item_name', $this->normalize($name))->first();
return $row ? (int) $row['category_id'] : null;
}
/**
* Zapamietaj wybor uzytkownika (upsert po znormalizowanej nazwie).
*/
public function remember(string $name, int $categoryId): void
{
$key = $this->normalize($name);
if ($key === '') {
return;
}
$existing = $this->where('item_name', $key)->first();
if ($existing) {
$this->update($existing['id'], ['category_id' => $categoryId, 'updated_at' => date('Y-m-d H:i:s')]);
} else {
$this->insert(['item_name' => $key, 'category_id' => $categoryId, 'updated_at' => date('Y-m-d H:i:s')]);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ReceiptItemModel extends Model
{
protected $table = 'receipt_items';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'receipt_id', 'name', 'display_name', 'qty', 'amount',
'suggested_category_id', 'chosen_category_id',
];
/**
* Pozycje danego paragonu w kolejnosci wprowadzenia.
*/
public function forReceipt(int $receiptId): array
{
return $this->where('receipt_id', $receiptId)
->orderBy('id', 'ASC')
->findAll();
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ReceiptModel extends Model
{
protected $table = 'receipts';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'file_path', 'original_name', 'mime', 'merchant',
'receipt_date', 'total', 'status', 'raw_json', 'error_msg',
];
public function hasOriginalName(string $name): bool
{
return $this->where('original_name', $name)->countAllResults() > 0;
}
/**
* Lista paragonow do historii, z liczba pozycji i utworzonych operacji.
*/
public function withCounts(): array
{
return $this->db->table('receipts r')
->select('r.*')
->select('(SELECT COUNT(*) FROM receipt_items ri WHERE ri.receipt_id = r.id) AS items_count', false)
->select('(SELECT COUNT(*) FROM operations o WHERE o.receipt_id = r.id) AS operations_count', false)
->orderBy('r.id', 'DESC')
->get()->getResultArray();
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class RememberTokenModel extends Model
{
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
protected $table = 'remember_tokens';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = ['selector', 'validator_hash', 'expires_at'];
public function issueToken(): string
{
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
$this->insert([
'selector' => $selector,
'validator_hash' => hash('sha256', $validator),
'expires_at' => date('Y-m-d H:i:s', time() + self::LIFETIME_SECONDS),
]);
return $selector . ':' . $validator;
}
public function verifyToken(string $token): bool
{
[$selector, $validator] = $this->splitToken($token);
if ($selector === null) {
return false;
}
$row = $this->where('selector', $selector)->first();
if (!$row || strtotime($row['expires_at']) <= time()) {
if ($row) {
$this->delete($row['id']);
}
return false;
}
return hash_equals($row['validator_hash'], hash('sha256', $validator));
}
public function deleteToken(string $token): void
{
[$selector] = $this->splitToken($token);
if ($selector !== null) {
$this->where('selector', $selector)->delete();
}
}
public function deleteExpired(): void
{
$this->where('expires_at <=', date('Y-m-d H:i:s'))->delete();
}
private function splitToken(string $token): array
{
$parts = explode(':', $token, 2);
if (count($parts) !== 2 || !ctype_xdigit($parts[0]) || strlen($parts[0]) !== 32
|| !ctype_xdigit($parts[1]) || strlen($parts[1]) !== 64) {
return [null, null];
}
return $parts;
}
}
+4
View File
@@ -16,6 +16,10 @@
<label class="form-label">Hasło</label> <label class="form-label">Hasło</label>
<input type="password" name="password" class="form-control" required> <input type="password" name="password" class="form-control" required>
</div> </div>
<div class="form-check mb-3">
<input type="checkbox" name="remember" value="1" class="form-check-input" id="remember">
<label class="form-check-label" for="remember">Zapamiętaj mnie przez 30 dni</label>
</div>
<button type="submit" class="btn btn-primary w-100">Zaloguj</button> <button type="submit" class="btn btn-primary w-100">Zaloguj</button>
<?= form_close() ?> <?= form_close() ?>
</div> </div>
+4
View File
@@ -28,6 +28,7 @@
<ul class="dropdown-menu" aria-labelledby="navFinance"> <ul class="dropdown-menu" aria-labelledby="navFinance">
<li><a class="dropdown-item" href="<?= site_url('finances') ?>">Pulpit finansów</a></li> <li><a class="dropdown-item" href="<?= site_url('finances') ?>">Pulpit finansów</a></li>
<li><a class="dropdown-item" href="<?= site_url('operations') ?>">Operacje</a></li> <li><a class="dropdown-item" href="<?= site_url('operations') ?>">Operacje</a></li>
<li><a class="dropdown-item" href="<?= site_url('receipts') ?>">Import paragonów</a></li>
<li><a class="dropdown-item" href="<?= site_url('categories') ?>">Kategorie</a></li> <li><a class="dropdown-item" href="<?= site_url('categories') ?>">Kategorie</a></li>
</ul> </ul>
</li> </li>
@@ -55,6 +56,9 @@
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div> <div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (session()->getFlashdata('warning')): ?>
<div class="alert alert-warning"><?= esc(session()->getFlashdata('warning')) ?></div>
<?php endif; ?>
<?= $this->renderSection('content') ?> <?= $this->renderSection('content') ?>
</main> </main>
+59
View File
@@ -0,0 +1,59 @@
<?= $this->extend('layout/main') ?>
<?= $this->section('content') ?>
<?php
$fmt = static fn ($v) => number_format((float) $v, 2, ',', ' ') . ' zł';
$badges = [
'parsing' => 'secondary',
'parsed' => 'info',
'failed' => 'danger',
'imported' => 'success',
];
?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4 mb-0">Import paragonów</h1>
<a href="<?= site_url('receipts/new') ?>" class="btn btn-primary">+ Nowy import</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Data</th>
<th>Sprzedawca</th>
<th class="text-end">Suma</th>
<th class="text-center">Pozycje</th>
<th class="text-center">Operacje</th>
<th>Status</th>
<th class="text-end">Akcje</th>
</tr>
</thead>
<tbody>
<?php if (empty($receipts)): ?>
<tr><td colspan="7" class="text-center text-muted py-4">Brak zaimportowanych paragonów.</td></tr>
<?php else: foreach ($receipts as $r): ?>
<tr>
<td><?= esc($r['receipt_date'] ?: mb_substr((string) $r['created_at'], 0, 10)) ?></td>
<td><?= esc($r['merchant'] ?: '—') ?></td>
<td class="text-end"><?= $r['total'] !== null ? $fmt($r['total']) : '—' ?></td>
<td class="text-center"><?= (int) $r['items_count'] ?></td>
<td class="text-center"><?= (int) $r['operations_count'] ?></td>
<td><span class="badge bg-<?= $badges[$r['status']] ?? 'secondary' ?>"><?= esc($r['status']) ?></span></td>
<td class="text-end">
<?php if ($r['status'] === 'parsed'): ?>
<a href="<?= site_url('receipts/' . $r['id'] . '/review') ?>" class="btn btn-sm btn-outline-primary">Przegląd</a>
<?php else: ?>
<a href="<?= site_url('receipts/' . $r['id']) ?>" class="btn btn-sm btn-outline-secondary">Podgląd</a>
<?php endif; ?>
<a href="<?= site_url('receipts/' . $r['id'] . '/delete') ?>" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Usunąć import?')">Usuń</a>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
<?= $this->endSection() ?>
+27
View File
@@ -0,0 +1,27 @@
<?= $this->extend('layout/main') ?>
<?= $this->section('content') ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4 mb-0">Nowy import paragonu</h1>
<a href="<?= site_url('receipts') ?>" class="btn btn-link">← Historia importów</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<?= form_open_multipart('receipts', ['class' => 'row g-3']) ?>
<div class="col-12">
<label class="form-label">Plik paragonu</label>
<input type="file" name="receipt" class="form-control" required
accept=".jpg,.jpeg,.png,.pdf,.json,image/*,application/pdf,application/json">
<div class="form-text">
Obsługiwane formaty: skan JPG/PNG, PDF lub JSON. Maks. 10 MB.
Po wgraniu AI odczyta pozycje i zaproponuje kategorie do zatwierdzenia.
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Wgraj i przetwórz</button>
</div>
<?= form_close() ?>
</div>
</div>
<?= $this->endSection() ?>
+116
View File
@@ -0,0 +1,116 @@
<?= $this->extend('layout/main') ?>
<?= $this->section('content') ?>
<?php $fmt = static fn ($v) => number_format((float) $v, 2, ',', ' ') . ' zł'; ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4 mb-0">Przegląd paragonu</h1>
<a href="<?= site_url('receipts') ?>" class="btn btn-link">← Historia importów</a>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body row g-2">
<div class="col-md-4"><span class="text-muted small d-block">Sprzedawca</span><?= esc($receipt['merchant'] ?: '—') ?></div>
<div class="col-md-4"><span class="text-muted small d-block">Data</span><?= esc($receipt['receipt_date'] ?: '—') ?></div>
<div class="col-md-4"><span class="text-muted small d-block">Suma z paragonu (AI)</span><?= $receipt['total'] !== null ? $fmt($receipt['total']) : '—' ?></div>
</div>
</div>
<?= form_open('receipts/' . $receipt['id'] . '/confirm') ?>
<div class="card shadow-sm mb-3">
<div class="card-header bg-white">
<strong>Pozycje</strong> — sprawdź i popraw nazwy/kwoty (AI potrafi się pomylić), przypisz kategorię
(podpowiedź zaznaczona; „— pomiń —” = nie księguj)
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:40%">Pozycja</th>
<th class="text-end" style="width:16%">Kwota (zł)</th>
<th>Kategoria</th>
</tr>
</thead>
<tbody>
<?php if (empty($items)): ?>
<tr><td colspan="3" class="text-center text-muted py-4">AI nie odczytało żadnych pozycji.</td></tr>
<?php else: foreach ($items as $it): ?>
<?php $sel = $it['chosen_category_id'] ?? $it['suggested_category_id']; ?>
<tr>
<td>
<input type="text" name="display[<?= $it['id'] ?>]" class="form-control form-control-sm"
value="<?= esc($it['display_name'] ?: $it['name']) ?>" maxlength="255">
<span class="text-muted small">z paragonu: <?= esc($it['name']) ?></span>
</td>
<td>
<input type="number" step="0.01" min="0" name="amount[<?= $it['id'] ?>]"
class="form-control form-control-sm text-end js-amount"
value="<?= esc(number_format((float) $it['amount'], 2, '.', '')) ?>">
</td>
<td>
<select name="chosen_category_id[<?= $it['id'] ?>]" class="form-select form-select-sm js-cat">
<option value="">— pomiń —</option>
<?php foreach ($categories as $c): ?>
<option value="<?= $c['id'] ?>" <?= (string) $sel === (string) $c['id'] ? 'selected' : '' ?>>
<?= esc($c['path_label']) ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header bg-white"><strong>Podgląd księgowania</strong> — jedna operacja na kategorię (suma)</div>
<div class="card-body">
<ul class="list-group" id="groupPreview"><li class="list-group-item text-muted">—</li></ul>
</div>
</div>
<div class="mb-4">
<button type="submit" class="btn btn-primary">Zaksięguj</button>
<a href="<?= site_url('receipts/' . $receipt['id'] . '/delete') ?>" class="btn btn-outline-danger"
onclick="return confirm('Usunąć ten import?')">Usuń import</a>
</div>
<?= form_close() ?>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
// Podglad grupowania po kategorii liczony na zywo z pol wiersza (vanilla, bez zaleznosci).
const fmt = (v) => v.toLocaleString('pl-PL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' zł';
const rows = Array.from(document.querySelectorAll('tbody tr')).filter((r) => r.querySelector('.js-cat'));
const preview = document.getElementById('groupPreview');
function labelFor(sel) {
return sel.options[sel.selectedIndex].text.trim();
}
function render() {
const groups = {};
rows.forEach((row) => {
const sel = row.querySelector('.js-cat');
if (!sel || !sel.value) return;
const amt = parseFloat(row.querySelector('.js-amount').value) || 0;
if (!groups[sel.value]) groups[sel.value] = { label: labelFor(sel), sum: 0 };
groups[sel.value].sum += amt;
});
const keys = Object.keys(groups);
if (!keys.length) {
preview.innerHTML = '<li class="list-group-item text-muted">Brak przypisanych pozycji — nic nie zostanie zaksięgowane.</li>';
return;
}
preview.innerHTML = keys.map((k) =>
`<li class="list-group-item d-flex justify-content-between"><span>${groups[k].label}</span><strong>${fmt(groups[k].sum)}</strong></li>`
).join('');
}
rows.forEach((row) => {
row.querySelector('.js-cat').addEventListener('change', render);
row.querySelector('.js-amount').addEventListener('input', render);
});
render();
</script>
<?= $this->endSection() ?>
+77
View File
@@ -0,0 +1,77 @@
<?= $this->extend('layout/main') ?>
<?= $this->section('content') ?>
<?php $fmt = static fn ($v) => number_format((float) $v, 2, ',', ' ') . ' zł'; ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4 mb-0">Paragon <?= esc($receipt['merchant'] ?: '#' . $receipt['id']) ?></h1>
<a href="<?= site_url('receipts') ?>" class="btn btn-link">← Historia importów</a>
</div>
<div class="row g-3">
<div class="col-lg-7">
<div class="card shadow-sm mb-3">
<div class="card-body row g-2">
<div class="col-6"><span class="text-muted small d-block">Data</span><?= esc($receipt['receipt_date'] ?: '—') ?></div>
<div class="col-6"><span class="text-muted small d-block">Suma (AI)</span><?= $receipt['total'] !== null ? $fmt($receipt['total']) : '—' ?></div>
<div class="col-6"><span class="text-muted small d-block">Status</span><?= esc($receipt['status']) ?></div>
<div class="col-6"><span class="text-muted small d-block">Plik</span>
<a href="<?= site_url('receipts/' . $receipt['id'] . '/file') ?>" target="_blank"><?= esc($receipt['original_name']) ?></a>
</div>
<?php if ($receipt['status'] === 'failed' && $receipt['error_msg']): ?>
<div class="col-12"><div class="alert alert-danger mb-0 mt-2"><?= esc($receipt['error_msg']) ?></div></div>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header bg-white"><strong>Pozycje</strong></div>
<div class="table-responsive">
<table class="table mb-0 align-middle">
<thead class="table-light"><tr><th>Pozycja</th><th class="text-end">Kwota</th></tr></thead>
<tbody>
<?php if (empty($items)): ?>
<tr><td colspan="2" class="text-center text-muted py-3">Brak pozycji.</td></tr>
<?php else: foreach ($items as $it): ?>
<tr><td><?= esc($it['display_name'] ?: $it['name']) ?></td><td class="text-end"><?= $fmt($it['amount']) ?></td></tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
<div class="mb-4">
<?php if ($receipt['status'] === 'parsed'): ?>
<a href="<?= site_url('receipts/' . $receipt['id'] . '/review') ?>" class="btn btn-primary">Przejdź do przeglądu</a>
<?php endif; ?>
<a href="<?= site_url('receipts/' . $receipt['id'] . '/delete') ?>" class="btn btn-outline-danger"
onclick="return confirm('Usunąć ten import?')">Usuń import</a>
</div>
</div>
<div class="col-lg-5">
<?php if ($receipt['status'] === 'imported'): ?>
<div class="card shadow-sm">
<div class="card-header bg-white"><strong>Zaksięgowane operacje</strong></div>
<ul class="list-group list-group-flush">
<?php foreach ($operations as $o): ?>
<li class="list-group-item d-flex justify-content-between">
<span><?= esc($o['category_name'] ?: '(bez kategorii)') ?></span>
<strong class="amount-expense"><?= $fmt($o['amount']) ?></strong>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (str_starts_with((string) $receipt['mime'], 'image/')): ?>
<div class="card shadow-sm mt-3">
<div class="card-header bg-white"><strong>Skan</strong></div>
<div class="card-body text-center">
<img src="<?= site_url('receipts/' . $receipt['id'] . '/file') ?>" class="img-fluid" alt="Skan paragonu">
</div>
</div>
<?php endif; ?>
</div>
</div>
<?= $this->endSection() ?>
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Models\ReceiptModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
/** @internal */
final class ReceiptModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $namespace = 'App';
protected $refresh = true;
public function testDetectsPreviouslyImportedFilename(): void
{
$model = new ReceiptModel();
$model->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'));
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
use App\Models\RememberTokenModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
/**
* @internal
*/
final class RememberTokenModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $namespace = 'App';
protected $refresh = true;
public function testTokensRemainIndependentAcrossDevices(): void
{
$model = new RememberTokenModel();
$first = $model->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));
}
}