11 KiB
KtoCo — wspólne wydatki dla grup, par i współlokatorów
Aplikacja webowa (PWA) do dzielenia się wydatkami w gospodarstwie domowym — kto ile wydał, kto komu jest winien, z historią, statystykami i rozliczeniami. Obsługuje dowolną liczbę osób w gospodarstwie i dowolną liczbę gospodarstw na użytkownika. Mobile-first, instalowalna na telefonie, działa częściowo offline.
Funkcje
- Dashboard — kafelki „kto komu ile jest winien" (automatycznie uproszczone do minimalnej liczby przelewów dla całej grupy) z przyciskiem „Rozlicz się", wykres kołowy wydatków wg kategorii, podsumowanie miesiąca.
- Dodawanie wydatku — duże pole kwoty, kategorie z ikonami, wybór płacącego, trzy tryby podziału: po równo (między wszystkich członków), dokładny podział, całość na jedną osobę.
- Historia — lista wydatków z filtrami (miesiąc/kategoria/płacący), edycja i usuwanie.
- Statystyki — wydatki miesiąc do miesiąca, porównanie konsumpcji członków gospodarstwa, ranking kategorii.
- Wiele gospodarstw — użytkownik może należeć do wielu gospodarstw jednocześnie i przełączać się między nimi (Ustawienia → „Twoje gospodarstwa"); każde gospodarstwo może mieć dowolną liczbę członków, dołączanych tym samym kodem zaproszenia.
- Ustawienia — przełącznik i zarządzanie gospodarstwami (zmiana nazwy/waluty, usuwanie członka, usunięcie całego gospodarstwa, zaproszenia), własne kategorie (z edycją nazwy/ikony), motyw jasny/ciemny/systemowy, powiadomienia mailowe, eksport CSV.
- Konto — rejestracja e-mail/hasło, logowanie, zmiana nazwy, zmiana hasła, przypomnienie/reset hasła mailem, usunięcie konta.
- PWA / offline — instalowalna na ekranie głównym telefonu, cache widoków, wydatki dodane offline trafiają do kolejki i synchronizują się automatycznie po powrocie sieci.
Stack technologiczny
| Warstwa | Technologia |
|---|---|
| Frontend | React (Vite), react-router-dom, TanStack Query, Recharts, vite-plugin-pwa, Material Symbols (Google Fonts) |
| Backend | Node.js + Express, better-sqlite3, JWT (jsonwebtoken), bcryptjs, nodemailer |
| Baza danych | SQLite (plik na bind mouncie ./sqlite) |
| Infrastruktura | Docker Compose, nginx (serwuje frontend + proxy /api), opcjonalnie Traefik (TLS + routing domenowy) |
Struktura projektu
ktoco/
├── docker-compose.yaml # jedyny plik potrzebny do uruchomienia całości
├── .env # konfiguracja (sekrety, domena, SMTP) — NIE commitować
├── .env.example # szablon konfiguracji do skopiowania
├── sqlite/ # bind mount — tu leży plik app.db (trwałość danych)
├── backend/
│ ├── Dockerfile
│ └── src/
│ ├── index.js # Express app, montowanie routerów
│ ├── db/ # schema.sql + połączenie better-sqlite3
│ ├── middleware/auth.js # weryfikacja JWT
│ ├── routes/ # auth, households, categories, expenses, settlements, stats
│ └── utils/ # obliczanie salda, mailer (nodemailer), pomocnicze household
└── frontend/
├── Dockerfile # multi-stage: build (node) -> serve (nginx)
├── nginx.conf # proxy /api -> backend:3000
├── vite.config.js # konfiguracja PWA (manifest, service worker)
└── src/
├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Login, Register, ...
├── components/ # BottomNav, wykresy, formularze, Icon, Switch, ...
├── api/ # klient fetch + hooki React Query
├── auth/ # kontekst autoryzacji (JWT w localStorage)
├── household/ # kontekst aktywnego gospodarstwa (lista + przełączanie)
├── theme/ # kontekst motywu jasny/ciemny/systemowy
└── offline/ # kolejka IndexedDB + synchronizacja po powrocie sieci
Uruchomienie
Wymagany jest tylko Docker (z pluginem Compose).
cp .env.example .env
# uzupełnij .env (patrz sekcja niżej) — nie trzeba edytować docker-compose.yaml
sudo docker compose up -d --build
Aplikacja będzie dostępna pod http://localhost:8856 (oraz pod domeną z Traefika, jeśli skonfigurowana — patrz niżej).
Konfiguracja (.env)
Cała konfiguracja wdrożeniowa znajduje się w .env — docker-compose.yaml nie wymaga edycji.
| Zmienna | Opis | Domyślnie |
|---|---|---|
JWT_SECRET |
Sekret do podpisywania tokenów logowania. Wygeneruj: openssl rand -hex 32 |
— (wymagany) |
FRONTEND_URL |
Publiczny adres aplikacji, używany w linkach w mailach (np. reset hasła) | https://ktoco.kzbikowski.pl |
DOMAIN |
Domena, pod którą Traefik wystawia aplikację | ktoco.kzbikowski.pl |
TRAEFIK_NETWORK |
Nazwa istniejącej zewnętrznej sieci Docker, do której podłączony jest Traefik | traefik_public |
SMTP_HOST |
Adres serwera SMTP. Puste = wysyłka maili wyłączona (tylko log w konsoli) | — (opcjonalne) |
SMTP_PORT |
Port SMTP | 587 |
SMTP_SECURE |
true dla połączenia SSL/TLS od razu (port 465), inaczej false (STARTTLS) |
false |
SMTP_USER / SMTP_PASS |
Dane logowania do SMTP | — |
SMTP_FROM |
Adres nadawcy w wysyłanych mailach | SMTP_USER |
Bez skonfigurowanego SMTP aplikacja działa normalnie — funkcje „reset hasła" i „powiadomienia mailowe" po prostu nie wysyłają realnych maili (backend loguje w konsoli, że wysyłkę pominięto).
Dane / trwałość
Baza SQLite leży w ./sqlite/app.db na hoście (bind mount, nie nazwany wolumen Dockera) — łatwo ją skopiować, zbackupować albo podejrzeć narzędziem sqlite3 bez wchodzenia do kontenera.
Wdrożenie za Traefikiem
Serwis frontend jest podłączony do zewnętrznej sieci traefik_public (nazwa konfigurowalna przez TRAEFIK_NETWORK) i ma etykiety Traefika (routing po domenie z .env, TLS przez tls-resolver). Warunek: sieć traefik_public musi już istnieć na hoście (tworzy ją zwykle stack samego Traefika):
docker network create traefik_public # tylko jeśli jeszcze nie istnieje
Port 8856 frontend jest dodatkowo opublikowany bezpośrednio na hosta — przydatne przy testach lokalnych równolegle z dostępem przez Traefik.
Model danych (SQLite)
users— konta (e-mail, hash hasła, preferencja powiadomień mailowych)households— gospodarstwa domowe (nazwa, waluta)household_members— członkowie gospodarstwa (dowolna liczba osób; użytkownik może być w wielu gospodarstwach naraz)invites— kody zaproszeń do gospodarstwa (ważne 7 dni, wielokrotnego użytku — nie wygasają po jednym dołączeniu)password_resets— jednorazowe tokeny resetu hasła (ważne 1h)categories— kategorie wydatków (nazwa, ikona Material Symbols, kolor)expenses— wydatki (kwota, płacący, kategoria, data, typ podziału)expense_shares— finalny podział wydatku między członków gospodarstwa (niezależnie od typu podziału zawsze sumuje się do kwoty wydatku)settlements— historia rozliczeń („Rozlicz się")
Saldo per osoba liczone jest jako: (suma zapłacona przez osobę) − (suma jej udziałów w wydatkach) − (netto rozliczeń). Do prezentacji „kto komu ile jest winien" salda są upraszczane zachłannym algorytmem (backend/src/utils/balance.js: simplifyDebts), który dla N osób generuje minimalną liczbę przelewów rozliczających wszystkich (zamiast osobnego długu między każdą parą).
Wiele gospodarstw — jak to działa
Użytkownik może należeć do wielu gospodarstw. Ponieważ każdy request do zasobów powiązanych z gospodarstwem (wydatki, kategorie, saldo, statystyki, rozliczenia) musi wiedzieć, którego gospodarstwa dotyczy, frontend wysyła nagłówek X-Household-Id: <id aktywnego gospodarstwa> przy każdym takim żądaniu (ustawiany automatycznie przez frontend/src/household/HouseholdContext.jsx po przełączeniu gospodarstwa w Ustawieniach). Backend weryfikuje w requireHousehold middleware, że zalogowany użytkownik faktycznie jest członkiem podanego gospodarstwa.
Usunięcie ostatniego członka z gospodarstwa automatycznie kasuje samo gospodarstwo (wraz z całą historią wydatków — kasowanie kaskadowe przez klucze obce). Usunięcie konta użytkownika, który ma współdzieloną historię finansową z innymi (wydatki/udziały/rozliczenia), nie usuwa go fizycznie z bazy (zepsułoby to historię widoczną dla reszty gospodarstwa) — konto jest wtedy anonimizowane (nazwa → „Usunięte konto", e-mail zastąpiony unikalnym nieistniejącym adresem, hasło unieważnione). Świeże konto bez żadnej historii jest usuwane w całości.
API (skrót)
Wszystkie endpointy poza /auth/register, /auth/login, /auth/forgot-password, /auth/reset-password i /health wymagają nagłówka Authorization: Bearer <token>. Endpointy gospodarstwa/kategorii/wydatków/rozliczeń/statystyk dodatkowo wymagają X-Household-Id: <id>.
| Grupa | Endpointy |
|---|---|
| Auth | POST /auth/register, /login, /change-password, /forgot-password, /reset-password, GET /auth/me, PUT /auth/me, PUT /auth/me/notifications, DELETE /auth/me |
| Gospodarstwa | GET /households (lista Twoich), GET/PUT/DELETE /households/:id, POST /households, POST /households/:id/invite, POST /households/join, DELETE /households/:id/members/:userId |
| Kategorie | GET/POST/PUT/DELETE /categories[/:id] |
| Wydatki | GET/POST/PUT/DELETE /expenses[/:id] (filtry: month, categoryId, payerId) |
| Rozliczenia | GET/POST /settlements (POST rozlicza od razu wszystkie uproszczone przelewy) |
| Statystyki | GET /stats/balance, /summary, /monthly, /export.csv |
Tryb offline (PWA)
Service worker (Workbox, przez vite-plugin-pwa) cache'uje powłokę aplikacji i ostatnio pobrane dane GET z API (strategia NetworkFirst), więc appka otwiera się i pokazuje dane nawet bez sieci. Nowy wydatek dodany offline trafia do kolejki w IndexedDB (frontend/src/offline/) i zostaje automatycznie wysłany po wykryciu powrotu połączenia (online event) — widoczny jest wtedy baner z liczbą oczekujących wpisów.
Znane ograniczenia
- Kopiowanie kodu zaproszenia przez
navigator.clipboardwymaga bezpiecznego kontekstu (HTTPS lublocalhost) — na zwykłym HTTP w sieci lokalnej przeglądarka może to zablokować; dlatego kod jest zawsze dostępny też jako zaznaczalne pole tekstowe (ręczne kopiowanie zawsze działa). schema.sqlużywaCREATE TABLE IF NOT EXISTS— dodanie nowej kolumny do istniejącej tabeli w już działającej bazie wymaga ręcznej migracji (ALTER TABLE); przy starcie na czystej bazie schemat tworzy się poprawnie od razu.- Kod zaproszenia do gospodarstwa nie wygasa po pierwszym użyciu (celowo — pozwala zaprosić dowolną liczbę osób tym samym kodem), tylko po czasie (7 dni) lub ręcznym wygenerowaniu nowego w Ustawieniach.
Rozwój lokalny (bez Dockera)
Wymaga Node.js 20+.
cd backend && npm install && JWT_SECRET=dev DATABASE_PATH=./data/app.db npm start
cd frontend && npm install && npm run dev # serwer dev na :5173, proxy /api -> :3000