/** * KIESZONKOWY KSIEGOWY - Twoja wlasna aplikacja do wydatkow. * * Co to robi: * - robisz zdjecie paragonu albo piszesz "biedronka 47 zl", * - AI odczytuje kwote, sklep i kategorie, * - aplikacja zapisuje wydatek i pokazuje, ile wydales w tym tygodniu i miesiacu. * * Czego potrzebujesz (wszystko za darmo): * 1. konta na Cloudflare, * 2. darmowego klucza do AI z Google AI Studio, * 3. 30 minut. * * Pelna instrukcja z obrazkami: https://kreatorklientow.pl/pierwsza-automatyzacja * * ------------------------------------------------------------------------- * USTAWIENIA - to jedyne miejsce, ktore warto zmieniac na poczatek * ------------------------------------------------------------------------- */ const USTAWIENIA = { tytul: 'Moje wydatki', // Waluta, w ktorej chcesz widziec sumy i budzet. walutaGlowna: 'PLN', // Waluty, ktore aplikacja ma rozpoznawac na paragonach. Paragon w innej // walucie niz glowna zostaje zapisany w swojej walucie, a do sum wchodzi // przeliczony po kursie z dnia. waluty: ['PLN', 'EUR'], // Kursy zapasowe: ile jednostek waluty glownej kosztuje 1 jednostka obcej. // Uzywane tylko wtedy, gdy darmowe kursy Europejskiego Banku Centralnego // nie odpowiedza. Warto raz na jakis czas poprawic. kursyZapasowe: { EUR: 4.3 }, // Kategorie, na ktore AI ma dzielic wydatki. Mozesz zmienic na swoje. kategorie: ['jedzenie', 'paliwo', 'dom', 'dzieci', 'zdrowie', 'rozrywka', 'inne'], // Miesieczny budzet w walucie glownej. Wpisz np. 2500, a karta "Ten miesiac" // pokaze, ile Ci jeszcze zostalo. 0 = wylaczone. budzet: 0, }; // Jak wyswietlac kazda walute. Czego tu nie ma, pokaze sie samym kodem (np. "SEK"). const SYMBOLE = { PLN: 'zł', EUR: '€', USD: '$', GBP: '£', CZK: 'Kč', CHF: 'CHF', NOK: 'NOK' }; // --------------------------------------------------------------------------- // Od tego miejsca w dol nie musisz nic rozumiec, zeby to dzialalo. // --------------------------------------------------------------------------- export default { async fetch(request, env) { const url = new URL(request.url); // Zabezpieczenie: bez PIN-u nikt nie zobaczy Twoich wydatkow. if (!env.PIN) { return new Response( 'Brakuje PIN-u. Wróć do instrukcji, do kroku 6 „Wklej klucz i ustaw PIN”.', { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } } ); } if (url.pathname === '/') { // "no-store" pilnuje, zeby przegladarka nie pokazala starej wersji aplikacji // po tym, jak wkleisz nowy kod. Bez tego mozna sie nabiedzic. return new Response(STRONA, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', }, }); } // Kazde zapytanie do danych musi miec poprawny PIN if (url.pathname.startsWith('/api/')) { const pin = request.headers.get('x-pin') || ''; if (pin !== env.PIN) { return json({ blad: 'Zly PIN' }, 401); } if (!env.DB) { return json({ blad: 'Brakuje bazy. Wróć do instrukcji, do kroku 4 „Podepnij bazę danych”.' }, 500); } try { await przygotujBaze(env); if (url.pathname === '/api/dodaj' && request.method === 'POST') { return await dodajWydatek(request, env); } if (url.pathname === '/api/lista') { return await pobierzListe(env); } if (url.pathname === '/api/usun' && request.method === 'POST') { const { id } = await request.json(); await env.DB.prepare('DELETE FROM wydatki WHERE id = ?').bind(id).run(); return json({ ok: true }); } } catch (e) { return json({ blad: e.message }, 500); } } return new Response('Nie ma takiej strony', { status: 404 }); }, }; const json = (dane, status = 200) => new Response(JSON.stringify(dane), { status, headers: { 'content-type': 'application/json; charset=utf-8', // Bez tego przegladarka potrafi pokazac stara liste wydatkow. 'cache-control': 'no-store', }, }); /** * Tworzy tabele przy pierwszym uzyciu. * Dzieki temu nie musisz nigdzie wpisywac zadnych polecen do bazy - * wystarczy, ze baze utworzysz i podepniesz. */ async function przygotujBaze(env) { await env.DB.prepare(` CREATE TABLE IF NOT EXISTS wydatki ( id INTEGER PRIMARY KEY AUTOINCREMENT, kwota REAL NOT NULL, sklep TEXT, kategoria TEXT, opis TEXT, data TEXT NOT NULL ) `).run(); // Dokladamy obsluge walut. Robimy to osobno, zeby aplikacja, ktora dziala // od dawna, nie zgubila starych wydatkow - one po prostu dostana zlotowki. // kwota - tyle bylo na paragonie, w jego walucie // waluta - w czym byl paragon (PLN, EUR, ...) // kwota_glowna - to samo przeliczone na walute glowna, tego uzywamy do sum // kurs - po ile bylo przeliczone; zostaje na zawsze przy wydatku, // zeby stary wydatek nie zmienial sie, gdy kurs skacze const { results: kolumny } = await env.DB.prepare('PRAGMA table_info(wydatki)').all(); const nazwy = kolumny.map((k) => k.name); if (!nazwy.includes('waluta')) { await env.DB.prepare( `ALTER TABLE wydatki ADD COLUMN waluta TEXT NOT NULL DEFAULT '${USTAWIENIA.walutaGlowna}'` ).run(); } if (!nazwy.includes('kurs')) { await env.DB.prepare('ALTER TABLE wydatki ADD COLUMN kurs REAL NOT NULL DEFAULT 1').run(); } if (!nazwy.includes('kwota_glowna')) { await env.DB.prepare('ALTER TABLE wydatki ADD COLUMN kwota_glowna REAL').run(); // stare wydatki byly w walucie glownej, wiec przeliczac nie ma czego await env.DB.prepare('UPDATE wydatki SET kwota_glowna = kwota WHERE kwota_glowna IS NULL').run(); } // Tu ladujemy kursy, zeby nie pytac banku o to samo przy kazdym paragonie. await env.DB.prepare(` CREATE TABLE IF NOT EXISTS kursy ( waluta TEXT PRIMARY KEY, kurs REAL NOT NULL, pobrany TEXT NOT NULL ) `).run(); } /** * Ile jednostek waluty glownej kosztuje 1 jednostka podanej waluty. * * Kursy bierzemy z Europejskiego Banku Centralnego przez frankfurter.dev - * za darmo i bez zadnego klucza. Odpytujemy raz na dobe, resztę czasu * korzystamy z tego, co lezy w bazie. Jak bank nie odpowie, bierzemy ostatni * zapamietany kurs, a jak i tego nie ma - kurs zapasowy z USTAWIEN. */ async function pobierzKurs(env, waluta) { if (waluta === USTAWIENIA.walutaGlowna) return 1; const zapisany = await env.DB .prepare('SELECT kurs, pobrany FROM kursy WHERE waluta = ?') .bind(waluta).first(); const swiezy = zapisany && Date.now() - Date.parse(zapisany.pobrany) < 24 * 60 * 60 * 1000; if (swiezy) return zapisany.kurs; try { const odp = await fetch( `https://api.frankfurter.dev/v1/latest?base=${waluta}&symbols=${USTAWIENIA.walutaGlowna}`, { signal: AbortSignal.timeout(5000) } ); if (odp.ok) { const dane = await odp.json(); const kurs = dane?.rates?.[USTAWIENIA.walutaGlowna]; if (typeof kurs === 'number' && kurs > 0) { await env.DB .prepare('INSERT INTO kursy (waluta, kurs, pobrany) VALUES (?, ?, ?) ' + 'ON CONFLICT(waluta) DO UPDATE SET kurs = excluded.kurs, pobrany = excluded.pobrany') .bind(waluta, kurs, new Date().toISOString()) .run(); return kurs; } } } catch (e) { // bank nie odpowiedzial - lecimy dalej na tym, co mamy } if (zapisany) return zapisany.kurs; return USTAWIENIA.kursyZapasowe[waluta] || 1; } /** * Wysyla zdjecie albo tekst do AI i prosi o wyciagniecie kwoty, sklepu i kategorii. */ async function zapytajAI(env, { tekst, zdjecieBase64, typZdjecia }) { const polecenie = `Jesteś asystentem do zapisywania wydatków domowych. Z tego, co dostaniesz, wyciągnij dane o JEDNYM wydatku. Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego komentarza, w formacie: {"kwota": 47.30, "waluta": "PLN", "sklep": "Biedronka", "kategoria": "jedzenie", "opis": "zakupy spożywcze"} Zasady: - "kwota" to liczba, suma do zapłaty. Na polskim paragonie szukaj SUMA, RAZEM albo DO ZAPŁATY, na niemieckim SUMME, GESAMT, ZU ZAHLEN albo TOTAL, - kwotę zapisz z kropką dziesiętną: 12.50, nigdy 12,50; ignoruj kropki i spacje oddzielające tysiące (1.234,56 to 1234.56), - "waluta" musi być jedną z: ${USTAWIENIA.waluty.join(', ')}. Rozpoznaj ją po tym, co jest na paragonie: zł, PLN, gr to PLN, a €, EUR, EURO to EUR. Paragon z niemieckiego, austriackiego czy holenderskiego sklepu (Rewe, Aldi, Lidl DE, Kaufland DE, stacje Aral, Shell DE, MwSt., Netto/Brutto po niemiecku) prawie zawsze jest w EUR, nawet jeśli symbolu waluty nigdzie nie widać, - jeśli naprawdę nie da się ustalić waluty, wpisz "${USTAWIENIA.walutaGlowna}", - "kategoria" musi być jedną z: ${USTAWIENIA.kategorie.join(', ')}, - "sklep" to nazwa sklepu lub firmy; jak nie wiadomo, wpisz "nieznany", - "opis" to maksymalnie 5 słów po polsku, - jeśli nie da się odczytać kwoty, ustaw "kwota": null.`; const mime = typZdjecia || 'image/jpeg'; const dostawca = rozpoznajDostawce(env.KLUCZ_AI); const surowy = dostawca === 'anthropic' ? await pytajAnthropic(env.KLUCZ_AI, polecenie, tekst, zdjecieBase64, mime) : dostawca === 'openai' ? await pytajOpenAI(env.KLUCZ_AI, polecenie, tekst, zdjecieBase64, mime) : await pytajGemini(env.KLUCZ_AI, polecenie, tekst, zdjecieBase64, mime); const wynik = odczytajJSON(surowy); if (!wynik) { throw new Error('Nie udało mi się odczytać tego wydatku. Zrób zdjęcie jeszcze raz, tak żeby było widać sumę, albo wpisz kwotę ręcznie.'); } return wynik; } /** * AI czasem opakowuje odpowiedz w ``` albo dopisze zdanie od siebie. * Zamiast wywalac sie z bledem, wylawiamy z tego obiekt JSON. */ function odczytajJSON(surowy) { const tekst = String(surowy || '') .trim() .replace(/^```(?:json)?\s*/i, '') .replace(/\s*```$/, '') .trim(); try { return JSON.parse(tekst); } catch {} // ostatnia szansa: wyciagamy pierwszy nawias klamrowy z zawartoscia const kawalek = tekst.match(/\{[\s\S]*\}/); if (kawalek) { try { return JSON.parse(kawalek[0]); } catch {} } return null; } /** * Rozpoznaje po samym kluczu, do kogo wyslac zapytanie. * Dzieki temu jest jeden sekret KLUCZ_AI, niezaleznie od tego, gdzie masz konto. */ function rozpoznajDostawce(klucz) { if (!klucz) return 'gemini'; if (klucz.startsWith('sk-ant-')) return 'anthropic'; if (klucz.startsWith('sk-')) return 'openai'; return 'gemini'; } async function sprawdzOdpowiedz(odp) { if (odp.ok) return; const tresc = await odp.text(); if (odp.status === 401 || odp.status === 403 || (odp.status === 400 && /api[ _-]?key/i.test(tresc))) { throw new Error('Klucz do AI jest nieprawidłowy. Sprawdź krok "Wklej klucz AI" w instrukcji.'); } if (odp.status === 429) { throw new Error('AI ma teraz za dużo zapytań. Poczekaj minutę i spróbuj jeszcze raz.'); } throw new Error(`AI nie odpowiedziało (błąd ${odp.status}). Spróbuj jeszcze raz za chwilę.`); } // Google co jakis czas wycofuje modele i nawet alias "latest" potrafi na chwile // przestac dzialac. Dlatego probujemy po kolei, az ktorys odpowie. Dzieki temu // aplikacja przezyje zmiany po stronie Google bez Twojej ingerencji. // Kolejnosc ma znaczenie: pierwsze sa te, ktore odpowiadaja najszybciej. // Zmierzone: flash-latest ~0.9 s, 3-flash-preview ~0.9 s, 3.5-flash ~8 s. const MODELE_GEMINI = ['gemini-flash-latest', 'gemini-3-flash-preview', 'gemini-3.5-flash']; // Ile czekamy na jeden model, zanim sprobujemy nastepnego. Bez tego jeden // zamulony model potrafi zawiesic cala aplikacje na minute. const LIMIT_CZASU_MS = 15000; // Zapamietany model, ktory ostatnio zadzialal (zeby nie pukac za kazdym razem do wycofanych). let dzialajacyModel = null; async function pytajGemini(klucz, polecenie, tekst, zdjecieBase64, mime) { /** @type {any[]} */ const czesci = [{ text: polecenie }]; if (zdjecieBase64) czesci.push({ inline_data: { mime_type: mime, data: zdjecieBase64 } }); if (tekst) czesci.push({ text: `Wydatek: ${tekst}` }); const doSprawdzenia = dzialajacyModel ? [dzialajacyModel, ...MODELE_GEMINI.filter((m) => m !== dzialajacyModel)] : MODELE_GEMINI; let ostatniStatus = 0; for (const model of doSprawdzenia) { let odp; try { odp = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${klucz}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: czesci }], generationConfig: { temperature: 0, responseMimeType: 'application/json', // Odpowiedz to kilka linijek JSON-a, wiecej nie potrzeba. maxOutputTokens: 300, // Model nie ma sie nad tym zastanawiac - to skraca czas // odpowiedzi kilkukrotnie, a wynik jest ten sam. thinkingConfig: { thinkingBudget: 0 }, }, }), signal: AbortSignal.timeout(LIMIT_CZASU_MS), } ); } catch (e) { // model nie odpowiedzial na czas - probujemy nastepnego ostatniStatus = 408; if (dzialajacyModel === model) dzialajacyModel = null; continue; } if (odp.ok) { dzialajacyModel = model; const dane = await odp.json(); // Nowsze modele potrafia zwrocic kilka czesci, w tym swoje "myslenie". // Bierzemy wszystko, co nie jest myslowe, i sklejamy. const czesciOdp = dane?.candidates?.[0]?.content?.parts || []; const tekst = czesciOdp .filter((p) => !p.thought && typeof p.text === 'string') .map((p) => p.text) .join('') .trim(); return tekst || '{}'; } // 404 = model wycofany, 500/503 = chwilowo niedostepny. Probujemy nastepny. if (odp.status === 404 || odp.status === 500 || odp.status === 503) { ostatniStatus = odp.status; if (dzialajacyModel === model) dzialajacyModel = null; continue; } // Kazdy inny blad (zly klucz, limit) ma trafic do uzytkownika od razu. await sprawdzOdpowiedz(odp); } throw new Error( `Wszystkie modele AI są w tej chwili niedostępne (ostatni błąd ${ostatniStatus}). Spróbuj za kilka minut.` ); } async function pytajOpenAI(klucz, polecenie, tekst, zdjecieBase64, mime) { /** @type {any[]} */ const tresc = [{ type: 'text', text: polecenie }]; if (zdjecieBase64) { tresc.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${zdjecieBase64}` } }); } if (tekst) tresc.push({ type: 'text', text: `Wydatek: ${tekst}` }); const odp = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${klucz}` }, body: JSON.stringify({ model: 'gpt-4o-mini', max_tokens: 500, temperature: 0, response_format: { type: 'json_object' }, messages: [{ role: 'user', content: tresc }], }), }); await sprawdzOdpowiedz(odp); const dane = await odp.json(); return dane?.choices?.[0]?.message?.content || '{}'; } async function pytajAnthropic(klucz, polecenie, tekst, zdjecieBase64, mime) { /** @type {any[]} */ const tresc = [{ type: 'text', text: polecenie }]; if (zdjecieBase64) { tresc.push({ type: 'image', source: { type: 'base64', media_type: mime, data: zdjecieBase64 } }); } if (tekst) tresc.push({ type: 'text', text: `Wydatek: ${tekst}` }); const odp = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': klucz, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: 'claude-opus-5', max_tokens: 2000, output_config: { effort: 'low', format: { type: 'json_schema', schema: { type: 'object', properties: { kwota: { anyOf: [{ type: 'number' }, { type: 'null' }] }, waluta: { type: 'string', enum: USTAWIENIA.waluty }, sklep: { type: 'string' }, kategoria: { type: 'string', enum: USTAWIENIA.kategorie }, opis: { type: 'string' }, }, required: ['kwota', 'waluta', 'sklep', 'kategoria', 'opis'], additionalProperties: false, }, }, }, messages: [{ role: 'user', content: tresc }], }), }); await sprawdzOdpowiedz(odp); const dane = await odp.json(); if (dane?.stop_reason === 'refusal') { throw new Error('AI odmówiło odczytania tego zdjęcia. Spróbuj z innym paragonem.'); } const blok = (dane?.content || []).find((b) => b.type === 'text'); return blok?.text || '{}'; } async function dodajWydatek(request, env) { if (!env.KLUCZ_AI) { return json({ blad: 'Brakuje klucza do AI. Wróć do instrukcji, do kroku 6 „Wklej klucz i ustaw PIN”.' }, 500); } const { tekst, zdjecie, typZdjecia } = await request.json(); if (!tekst && !zdjecie) return json({ blad: 'Wyślij zdjęcie paragonu albo napisz, co kupiłeś.' }, 400); const wynik = await zapytajAI(env, { tekst, zdjecieBase64: zdjecie, typZdjecia }); if (wynik.kwota == null) { return json({ blad: 'Nie udało mi się odczytać kwoty. Zrób zdjęcie tak, żeby było widać sumę, albo napisz kwotę ręcznie.' }, 422); } // Jak piszesz z reki "tankowanie 60 euro", to Twoje slowo jest wazniejsze // niz to, co wymyslilo AI. const waluta = rozpoznajWalute(wynik.waluta, tekst); const kurs = await pobierzKurs(env, waluta); const wydatek = { kwota: Number(wynik.kwota), waluta, kurs, kwota_glowna: Math.round(Number(wynik.kwota) * kurs * 100) / 100, sklep: wynik.sklep || 'nieznany', kategoria: USTAWIENIA.kategorie.includes(wynik.kategoria) ? wynik.kategoria : 'inne', opis: wynik.opis || '', data: new Date().toISOString(), }; const zapis = await env.DB .prepare('INSERT INTO wydatki (kwota, waluta, kurs, kwota_glowna, sklep, kategoria, opis, data) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)') .bind(wydatek.kwota, wydatek.waluta, wydatek.kurs, wydatek.kwota_glowna, wydatek.sklep, wydatek.kategoria, wydatek.opis, wydatek.data) .run(); return json({ ok: true, wydatek, id: zapis.meta.last_row_id }); } /** * Sprowadza to, co przyszlo z AI albo z Twojego wpisu, do kodu waluty (PLN, EUR). * AI potrafi zwrocic "euro", "€" albo "zl" zamiast kodu, a czlowiek tym bardziej. */ function rozpoznajWalute(zAI, tekst) { const zNapisu = (co) => { const s = String(co || '').toLowerCase(); if (!s) return null; if (/€|\beur/.test(s)) return 'EUR'; if (/zł|\bzl\b|\bpln\b|\bz[łl]ot/.test(s)) return 'PLN'; if (/\$|\busd\b|\bdolar/.test(s)) return 'USD'; if (/£|\bgbp\b|\bfunt/.test(s)) return 'GBP'; const kod = s.trim().toUpperCase(); return USTAWIENIA.waluty.includes(kod) ? kod : null; }; // Kolejnosc ma znaczenie: najpierw to, co sam napisales, potem AI. const wybor = zNapisu(tekst) || zNapisu(zAI); return wybor && USTAWIENIA.waluty.includes(wybor) ? wybor : USTAWIENIA.walutaGlowna; } async function pobierzListe(env) { const { results: wydatki } = await env.DB .prepare('SELECT id, kwota, waluta, kurs, kwota_glowna, sklep, kategoria, opis, data ' + 'FROM wydatki ORDER BY data DESC LIMIT 50') .all(); // Podsumowania: ten tydzien (od poniedzialku) i ten miesiac const teraz = new Date(); const dzienTygodnia = (teraz.getUTCDay() + 6) % 7; // poniedzialek = 0 const poczatekTygodnia = new Date(teraz); poczatekTygodnia.setUTCDate(teraz.getUTCDate() - dzienTygodnia); poczatekTygodnia.setUTCHours(0, 0, 0, 0); const poczatekMiesiaca = new Date(Date.UTC(teraz.getUTCFullYear(), teraz.getUTCMonth(), 1)); // Sumy licza sie z kwot przeliczonych na walute glowna - inaczej euro // wpadaloby do sumy jak zlotowki i miesiac wygladalby na tanszy niz byl. const sumaOd = async (od) => { const r = await env.DB .prepare('SELECT COALESCE(SUM(kwota_glowna), 0) AS s FROM wydatki WHERE data >= ?') .bind(od.toISOString()).first(); return Math.round(r.s * 100) / 100; }; // Osobny licznik: ile z tego poszlo w obcej walucie, w jej wlasnych kwotach. const obceOd = async (od) => { const { results } = await env.DB .prepare('SELECT waluta, ROUND(SUM(kwota), 2) AS suma FROM wydatki ' + 'WHERE data >= ? AND waluta <> ? GROUP BY waluta ORDER BY suma DESC') .bind(od.toISOString(), USTAWIENIA.walutaGlowna).all(); return results.map((r) => ({ waluta: r.waluta, suma: r.suma })); }; const { results: kategorie } = await env.DB .prepare('SELECT kategoria, ROUND(SUM(kwota_glowna), 2) AS suma FROM wydatki WHERE data >= ? GROUP BY kategoria ORDER BY suma DESC') .bind(poczatekMiesiaca.toISOString()).all(); return json({ wydatki, tydzien: await sumaOd(poczatekTygodnia), miesiac: await sumaOd(poczatekMiesiaca), obceTydzien: await obceOd(poczatekTygodnia), obceMiesiac: await obceOd(poczatekMiesiaca), budzet: USTAWIENIA.budzet || 0, wgKategorii: Object.fromEntries(kategorie.map((k) => [k.kategoria, k.suma])), }); } // --------------------------------------------------------------------------- // STRONA - to jest Twoja aplikacja. Otwierasz ja na telefonie. // --------------------------------------------------------------------------- const STRONA = ` ${USTAWIENIA.tytul}

${USTAWIENIA.tytul}

Podaj swój PIN, żeby zobaczyć wydatki.

`;