Przejdź do głównej zawartości

Niezawodność integracji z Fakturownią

Dokument opisuje, jak system zachowuje się, gdy Fakturownia odpowiada wolno lub nie odpowiada wcale, oraz jakie mechanizmy chronią przed wystawieniem zdublowanych paragonów i faktur.

👤 Instrukcja dla pracownika (Recepcja / Sklepik)

Co się dzieje, gdy Fakturownia nie odpowiada

Wystawienie paragonu w sklepiku (Dashboard ➔ Sklepik ➔ Sprzedaż) składa się z dwóch niezależnych operacji:

  1. Zapis sprzedaży w AcePark – zawsze wykonywany jako pierwszy. Sprzedaż i stany magazynowe są zapisywane w bazie systemu niezależnie od tego, czy Fakturownia odpowie.
  2. Wystawienie dokumentu w Fakturowni – wykonywane po zapisaniu sprzedaży.

Jeżeli Fakturownia nie odpowie w wyznaczonym czasie, system:

  • ponawia próbę samodzielnie (dla operacji, które można bezpiecznie powtórzyć),
  • sprawdza, czy paragon mimo braku odpowiedzi został jednak wystawiony,
  • dopiero gdy dokumentu naprawdę nie ma – wystawia go ponownie.

Komunikat "Fakturownia nie odpowiada"

Gdy mimo powyższych prób nie udało się potwierdzić wystawienia dokumentu, pracownik zobaczy komunikat:

Fakturownia nie odpowiada. Sprzedaż została zapisana - sprawdź listę paragonów za chwilę, zanim wystawisz go ponownie.

Co należy zrobić:

  1. Nie powtarzaj sprzedaży w sklepiku – sprzedaż i stan magazynowy są już zapisane.
  2. Odczekaj chwilę i sprawdź listę dokumentów w Fakturowni.
  3. Jeżeli paragonu nadal nie ma, wystaw dokument ręcznie w panelu Fakturowni.

Dlaczego nie ma zdublowanych paragonów

Każdy dokument tworzony przez system otrzymuje unikalny identyfikator operacji (np. sale-ace-park-1234 dla sprzedaży w sklepiku). Jeżeli system ponawia próbę wystawienia dokumentu, najpierw sprawdza w Fakturowni, czy dokument z takim identyfikatorem już istnieje. Jeśli tak – używa istniejącego dokumentu zamiast tworzyć kolejny.

Analogicznie działa fiskalizacja: ponowna próba fiskalizacji już zafiskalizowanego paragonu jest traktowana jako sukces, a nie jako błąd.


🛠️ Dokumentacja techniczna

Kontekst

Wywołania do Fakturowni przechodzą przez makeFakturowniaRequest (lib/fakturownia.ts). Do tej pory każde żądanie miało sztywny limit 15 s, a przekroczenie limitu kończyło się wyjątkiem The operation was aborted due to timeout, który propagował do Server Action bez żadnej próby odzyskania. Dla operacji zapisu (POST /invoices.json) oznaczało to sytuację nierozstrzygniętą: dokument mógł powstać po stronie Fakturowni, ale system o tym nie wiedział.

Warstwy mechanizmu

lib/fakturownia-retry.ts

Moduł w konwencji istniejących lib/d1-retry.ts i lib/do-retry.ts.

ElementOpis
FakturowniaTimeoutErrorTypowany błąd przekroczenia limitu czasu; niesie endpoint, method i timeoutMs.
FakturowniaHttpErrorTypowany błąd odpowiedzi HTTP; niesie status, statusText oraz surowe responseText.
isFakturowniaTimeoutErrorRozpoznaje timeout – używane do wyświetlenia dedykowanego komunikatu w UI.
isFakturowniaTransientErrorKlasyfikuje błąd jako przejściowy: timeout, zerwane połączenie oraz statusy 408, 425, 429, 500, 502, 503, 504.
getFakturowniaErrorTextZwraca treść odpowiedzi Fakturowni (a nie pełny komunikat z ciałem żądania).
withFakturowniaRetryDo 2 ponowień z opóźnieniem 500 ms i 1500 ms (z jitterem ±50%).

Błędy 4xx (poza 408, 425 i 429) nie są ponawiane – to błędy walidacji, a nie awarie.

makeFakturowniaRequest

Sygnatura przyjmuje teraz FakturowniaRequestOptions extends RequestInit:

OpcjaDomyślnieZnaczenie
timeoutMs15_000Limit czasu pojedynczej próby.
retrytrue dla GET, false dla resztyCzy żądanie może zostać bezpiecznie powtórzone.

Odczyty (GET) są ponawiane automatycznie. Zapisy nie są ponawiane bez wyraźnej zgody wywołującego (retry: true), aby nie tworzyć duplikatów.

Przy okazji naprawiono dwa błędy w budowaniu żądania:

  • nagłówki Accept i Content-Type były gubione, gdy wywołujący przekazał własne headers (spread ...options nadpisywał scalony obiekt),
  • signal przekazany przez wywołującego kasował sygnał timeoutu; obecnie oba sygnały są łączone.

Ochrona tokenu API

Treść błędu Fakturowni trafia do logów oraz – w sklepiku – do komunikatu widocznego dla pracownika. Komunikat wyjątku nie zawiera już ciała żądania (Request Body: ...), które przy tworzeniu dokumentu niosło api_token. Dodatkowo redactApiToken() maskuje token w treści odpowiedzi i w logowanym ciele żądania, zarówno w formie JSON ("api_token":"..."), jak i w query stringu (api_token=...).

Idempotentne wystawianie dokumentów

createInvoiceIdempotent(invoice, { idempotencyKey }) wykorzystuje pola oid i oid_unique API Fakturowni jako klucz idempotencji:

  1. POST /invoices.json z oid = idempotencyKey oraz oid_unique: 'yes' (bez automatycznych ponowień).
  2. Przy błędzie przejściowym lub odrzuceniu z powodu zajętego oidfindInvoiceByOid(oid) (GET, period: 'last_30_days').
  3. Gdy wąskie okno nic nie zwróci, a budżet na to pozwala – druga próba z period: 'all'. Pokrywa to dwa przypadki: nieudany pojedynczy odczyt (błędy GET są połykane i dają null) oraz dokument starszy niż 30 dni, którego oid nadal blokuje ponowne wystawienie.
  4. Gdy dokument istnieje – zwracany jest istniejący dokument.
  5. Gdy dokumentu nie ma, a błąd był przejściowy – ponowna próba utworzenia, a po niej ostatnia weryfikacja (również wąska, a potem szeroka).
  6. Błędy walidacji (np. brak pozycji) propagują natychmiast, bez dodatkowego zapytania.

Budżet czasu

Cała powyższa sekwencja mieści się w jednym budżecie czasu (budgetMs, domyślnie 30 s). Przed każdym kolejnym krokiem sprawdzany jest pozostały czas, a timeoutMs pojedynczej próby jest przycinany do tego, co zostało z budżetu. Gdy budżet się wyczerpie (mniej niż 3 s), funkcja przerywa odzyskiwanie i propaguje błąd zamiast kontynuować kolejne próby.

Ma to znaczenie na stanowisku sprzedaży: bez budżetu cztery kolejne kroki po 15 s (plus ponowienia odczytu) mogły utrzymać Server Action w oczekiwaniu przez ponad 2 minuty, co w praktyce prowadziłoby do ponownego kliknięcia "sprzedaj" przez pracownika – a nowa sprzedaż to nowy saleId, czyli nowy oid i realny duplikat paragonu. Budżet zamyka odzyskiwanie w ~30 s, po których pracownik dostaje jednoznaczny komunikat.

findInvoiceByOid dopasowuje dokument ściśle po polu oid. Dokument bez zgodnego oid nigdy nie zostanie uznany za "ten sam" – w najgorszym przypadku powstanie duplikat (tak jak dotychczas), ale sprzedaż nigdy nie zostanie powiązana z cudzym paragonem.

Konwencja kluczy: <domena>-<tenant>-<id encji>, np. sale-ace-park-1234.

Zastosowanie w sklepiku

createSaleWithReceipt (lib/actions/shop/index.ts):

  • paragon wystawiany jest przez createInvoiceIdempotent z kluczem sale-${tenantId}-${saleId},
  • fiskalizacja (/invoices/fiscal_print) jest wywoływana z retry: true i timeoutMs: 10_000 – powtórzenie jest bezpieczne, ponieważ Fakturownia odrzuca ponowną fiskalizację, a skrócony limit trzyma najgorszy przypadek całej akcji w okolicach minuty,
  • odpowiedź został już zafiskalizowany jest interpretowana jako sukces (isAlreadyFiscalizedError), a nie błąd,
  • timeout kończy się komunikatem errors.fakturowniaTimeout zamiast surowej treści wyjątku.

Ta sama funkcja isAlreadyFiscalizedError zastąpiła dotychczasowe porównanie tekstowe w lib/actions/receipt-management.ts.

Zastosowanie w lib/actions/invoice-generation.ts

Wszystkie wywołania POST /invoices.json w generateInvoiceFromPayment, generateBulkInvoiceFromPayments oraz generateReceiptsForSplitPayment korzystają z createInvoiceIdempotent zamiast bezpośredniego makeFakturowniaRequest. Wcześniej te zapisy nie były ponawiane (POST domyślnie ma retry: false), więc timeout kończył się twardym błędem bez próby odzyskania, mimo że dokument mógł faktycznie powstać po stronie Fakturowni.

Klucze idempotencji podążają za konwencją <domena>-<tenant>-<id encji> (i są skracane do 40 znaków, jeśli trzeba – patrz Limit długości oid):

ŚcieżkaKlucz
Faktura VAT za pojedynczą płatnośćpayment-<tenant>-<paymentId>-invoice
Faktura imienna za pojedynczą płatnośćpayment-<tenant>-<paymentId>-nominal-invoice
Paragon za pojedynczą płatnośćpayment-<tenant>-<paymentId>-receipt
Faktura VAT za grupę płatności (zbiorcza)payment-<tenant>-<klucz grupy>-bulk-invoice
Faktura imienna za grupę płatnościpayment-<tenant>-<klucz grupy>-bulk-nominal-invoice
Paragon za grupę płatnościpayment-<tenant>-<klucz grupy>-bulk-receipt
Paragon za powiązaną płatność (split payment)payment-<tenant>-linked-<linkedPaymentId>-receipt

Limit długości oid (40 znaków)

Fakturownia odrzuca dokument, którego oid przekracza 40 znaków, odpowiedzią 422 {"oid":["- pole jest za długie (maksymalna ilość znaków: 40)"]}. Błąd nie jest ani przejściowy, ani duplikatem, więc kończył się twardym błędem i żaden dokument nie powstawał – dla paragonów oznacza to problem fiskalny, a nie tylko brak wygody.

Klucze zbiorcze przekraczały ten limit dla realnych danych (payment-ace-park-26368x9-9exgtf-bulk-receipt to 44 znaki, …-bulk-nominal-invoice – 52), więc każde wystawienie dokumentu zbiorczego kończyło się niepowodzeniem. Klucze pojedynczych płatności (maks. 38 znaków) mieściły się w limicie.

normalizeFakturowniaOid (lib/fakturownia.ts) wymusza limit w jednym miejscu – tuż przed ustawieniem oid w createInvoiceIdempotent – więc żadne przyszłe wywołanie nie może go naruszyć:

  • klucz mieszczący się w 40 znakach przechodzi bez zmian, dzięki czemu dokumenty wystawione wcześniej pozostają uzgadnialne po tym samym oid,
  • dłuższy klucz jest deterministycznie skracany do <prefiks>-<hash FNV-1a całego klucza>. Hash liczony jest z pełnego klucza, więc rodzaj dokumentu (bulk-receipt / bulk-invoice / bulk-nominal-invoice) i skład grupy pozostają rozróżnialne, mimo że przycięty prefiks jest dla nich wspólny.

Ta sama wartość trafia do POST i do findInvoiceByOid, więc uzgadnianie po timeoucie szuka dokładnie tego oid, który został wysłany.

Klucz grupy płatności

buildPaymentGroupKey (lib/utils/payment-group-key.ts) buduje klucz z wszystkich id w grupie: sortuje je rosnąco i skleja w <najniższe id>x<liczba płatności>-<hash FNV-1a>, np. 100x3-1n4kqz.

Klucz musi zależeć od całego składu grupy, a nie od pierwszej płatności. Dokument zbiorczy obejmuje wszystkie płatności w grupie, a płatności bez invoice_id/receipt_id mogą trafić do generowania ponownie – właśnie po nieudanym zapisie, który idempotencja ma ratować. Gdyby kluczem było id pierwszej płatności, grupa {100, 101} i późniejsza grupa {100, 101, 140} dałyby ten sam klucz: Fakturownia odrzuciłaby POST z powodu zajętego oid, uzgodnienie zwróciłoby stary, mniejszy dokument, a płatność 140 zostałaby oznaczona jako udokumentowana, nigdy nie trafiając na żaden paragon ani fakturę. Hash całego zestawu daje inny klucz dla innego składu, a sortowanie sprawia, że kolejność wierszy z bazy (zapytanie nie ma ORDER BY) nie zmienia klucza.

Budżet całego przebiegu

processPaymentStatusChange i generateReceiptsForSplitPayment iterują po grupach/płatnościach sekwencyjnie, więc budżet pojedynczego dokumentu nie ogranicza czasu całej akcji. Oba przebiegi zakładają wspólny budżet FAKTUROWNIA_RUN_BUDGET_MS (60 s), z którego:

  • każde wywołanie createInvoiceIdempotent dostaje budgetMs równe temu, co zostało (nie więcej niż domyślne 30 s),
  • generateInvoiceFromPayment i generateBulkInvoiceFromPayments dzielą jeden budżet między dokument główny i awaryjny paragon – wcześniej każdy z nich mógł zająć pełne 30 s,
  • gdy z budżetu zostaje mniej niż jedna sensowna próba (3 s), pozostałe grupy są pomijane bez odpytywania Fakturowni, a ich id trafiają do jednego powiadomienia krytycznego („Wystaw dokumenty ręcznie") zamiast do serii timeoutów.

Dzięki temu masowe oznaczanie płatności jako opłacone przy niedostępnej Fakturowni kończy się w okolicach minuty, a nie po czasie przekraczającym limit krawędzi Cloudflare (524), który zostawiłby część płatności udokumentowaną, a część bez informacji zwrotnej.

Testy

  • lib/fakturownia-retry.test.ts – klasyfikacja błędów i polityka ponowień.
  • lib/fakturownia.test.ts – budowanie żądania, mapowanie timeoutu, polityka retry dla GET/POST, maskowanie tokenu API oraz wszystkie ścieżki createInvoiceIdempotent (reużycie dokumentu, ponowne wystawienie, duplikat oid, poszerzenie okna wyszukiwania, ponowienie nieudanego odczytu, błąd walidacji, wyczerpany budżet czasu).
  • __tests__/lib/missing-documents-report.test.ts – okno karencji i zakres zapytania, grupowanie, cisza przy braku zaległości, wysyłka do wszystkich adresatów, escapowanie HTML oraz zachowanie przy odbiciu jednego i wszystkich adresów.
  • __tests__/lib/cron-schedule.test.ts – para 20:00/21:00 UTC przepuszcza dokładnie jeden przebieg dziennie po obu stronach zmiany czasu i nie koliduje z parą poranną.
  • __tests__/api/regenerate-missing-documents-route.test.ts – autoryzacja, walidacja wejścia, limit partii, filtrowanie do płatności bez dokumentu, grupowanie na przebiegu na sucho oraz wymuszenie printReceipt: false.
  • lib/utils/payment-group-key.test.ts – stabilność klucza grupy względem kolejności oraz jego zmiana po dołączeniu lub odejściu płatności.
  • lib/fakturownia.test.ts (normalizeFakturowniaOid) – przepuszczanie krótkich kluczy bez zmian, skracanie długich do 40 znaków, determinizm oraz rozróżnialność rodzajów dokumentu i składów grupy po skróceniu.

Odzyskiwanie zaległych dokumentów

Gdy generowanie dokumentu padnie (timeout, odrzucenie przez Fakturownię), płatność zostaje z invoice_id i receipt_id równymi NULL. Takie płatności można znaleźć zapytaniem:

SELECT id, player_id, payment_type, paid_at, amount
FROM payment
WHERE status IN ('paid','paid_cash','paid_card','paid_online','paid_wallet')
AND invoice_id IS NULL AND receipt_id IS NULL
AND archived = 0
ORDER BY paid_at;

👤 Jak wystawić zaległe dokumenty

Endpoint POST /api/invoices/regenerate-missing (tylko ADMIN i BACKOFFICE) wystawia brakujące dokumenty dla wskazanych płatności:

{ "paymentIds": [26368, 26369, 26370], "dryRun": true }

Z dryRun: true nic nie jest wystawiane – odpowiedź pokazuje tylko, ile dokumentów powstanie i jak płatności zostaną pogrupowane. Bez dryRun dokumenty są wystawiane naprawdę. Zawsze warto najpierw zrobić przebieg na sucho.

Odpowiedź zawiera documented (ile udokumentowano), stillMissing (płatności, które nadal nie mają dokumentu – wymagają kolejnej próby lub ręcznego wystawienia) oraz skipped (płatności pominięte, bo już mają dokument albo nie są opłacone).

🛠️ Zachowanie techniczne

  • Endpoint deleguje do processPaymentStatusChange, więc płatności są grupowane tak samo jak w normalnym przepływie (player_id + payment_type) – dziewięć płatności jednego gracza da jeden dokument zbiorczy, a nie dziewięć osobnych.
  • Ponowne wywołanie jest bezpieczne: filtr invoice_id IS NULL AND receipt_id IS NULL jest stosowany zarówno w endpointcie, jak i w processPaymentStatusChange, więc płatność z dokumentem nigdy nie dostanie drugiego.
  • Daty dokumentu (issue_date, sell_date, paid_date) pochodzą z paid_at płatności, a nie z chwili odzyskiwania – paragon wystawiony po fakcie ma datę faktycznej zapłaty.
  • printReceipt jest wymuszone na false, więc odzyskiwanie nigdy nie uruchamia drukarki fiskalnej. Płatności online (p24, transfer) i tak idą ścieżką processOnlinePaymentReceipt (e-paragon + mail).
  • Limit MAX_PAYMENTS_PER_RUN (50) chroni przed przekroczeniem czasu żądania; większe zaległości trzeba podzielić na partie.

Nocny raport braków

Timeouty z 18.08.2026 pokazały lukę w wykrywaniu: płatność bez dokumentu nie dawała żadnego sygnału poza wpisem w logach. Nikt nie wie o problemie, dopóki ktoś nie zajrzy do tabeli logs.

👤 Co przychodzi na maila

Codziennie o 22:00 czasu polskiego, przed zamknięciem i rozliczeniem kortów, na [email protected] i [email protected] idzie raport płatności, które są opłacone, ale nie mają ani paragonu, ani faktury. Każdy wiersz to jeden dokument do wystawienia – płatności jednego gracza tego samego typu są zgrupowane tak, jak trafiłyby na dokument zbiorczy.

Mail przychodzi tylko wtedy, gdy są braki. Cisza oznacza, że wszystko jest udokumentowane. Jeśli ta sama lista przyjdzie następnego dnia, wystawienie się nie powiodło i trzeba sprawdzić logi integracji.

Zaległości z raportu wystawia się endpointem opisanym w Odzyskiwanie zaległych dokumentów.

🛠️ Zachowanie techniczne

handleMissingDocumentsReport (lib/missing-documents-report.ts) działa na workerze ADMIN, jak wszystkie crony.

  • Okno karencji 30 minut (MISSING_DOCUMENTS_GRACE_MINUTES) – płatność rozliczona chwilę przed cronem może być jeszcze w drodze do Fakturowni i nie jest zgłaszana jako brak.

  • Zakres 30 dni wstecz (MISSING_DOCUMENTS_LOOKBACK_DAYS) ogranicza raport do braków, które da się jeszcze sensownie odzyskać.

  • Pomijane są płatności na kwotę 0generateBulkInvoiceFromPayments odpowiada dla nich NO_DOCUMENT_REQUIRED i nie zapisuje dokumentu, więc raz zgłoszone wisiałyby w raporcie codziennie, bez możliwości wyczyszczenia.

  • Pomijane są płatności rozliczone w całości z portfela (paid_wallet). Portfel zasilany jest zwrotami (processRefundToWallet), a nie doładowaniami, więc wydanie jego środków nie przenosi nowych pieniędzy – paragon powstał już przy pierwotnej płatności. Żadna z 299 płatności paid_wallet w bazie nigdy nie dostała dokumentu; to reguła, nie awaria. Płatności rozliczone częściowo z portfela zachowują status drugiej metody (paid_cash, paid_card, paid_online) i pozostają w zakresie – i faktycznie wszystkie mają dokumenty.

  • Przeniesienia cyklu nie trafiają do raportu, bo dziedziczą dokument. executeRecurringTransfer zatrzymuje pieniądze ze starego cyklu (status = 'refunded', funds_retained = 1) i zakłada nowe płatności ze statusem paid, choć do kasy nie wpłynął ani grosz. Te nowe wiersze przejmują receipt_id / receipt_number / invoice_id / invoice_number / e_receipt_view_url po źródle (takeCarriedDocument), więc raport ich nie widzi, a klient widzi paragon tam, gdzie faktycznie leżą jego pieniądze. Szczegóły alokacji: Przeniesienie cyklu zajęć. Przed tą zmianą przeniesienie zgłaszało się jako luka fiskalna – wystawienie z raportu wygenerowałoby drugi paragon na te same pieniądze.

Powyższe wykluczenia dotyczą wyłącznie raportu. Endpoint odzyskiwania przyjmuje dowolne paymentIds, więc gdyby księgowość zdecydowała inaczej, da się wystawić dokument ręcznie bez zmiany kodu.

  • Para cronów 20:00 i 21:00 UTC. Tylko jedna z nich jest 22:00 w Polsce i zmienia się to z czasem letnim; skipOutsideClosing() w worker-crons.ts odrzuca drugą, dokładnie jak dla pary porannej. Godzina siedzi w CLOSING_HOUR (lib/cron-schedule.ts).

  • Klucz crona missing_documents_report – da się go wyłączyć w panelu bez deployu (migracja 0232).

  • Mail wychodzi wyłącznie z produkcji. Ten sam kod i ten sam cron działają w każdym środowisku, a raport o zaseedowanych kontach testowych wygląda w skrzynce identycznie jak realna luka fiskalna. Produkcja deklaruje się jawnie przez REPORT_ENVIRONMENT = "production" w [vars] w wrangler.admin.toml; każde inne środowisko – łącznie z workerem, który nie ustawia żadnych zmiennych – tylko zapisuje ostrzeżenie do logów i nie wysyła nic.

    :::warning Zmienna musi być na gałęzi main REPORT_ENVIRONMENT należy do produkcyjnej wersji wrangler.admin.toml, czyli tej z gałęzi main. Wersja z develop celowo jej nie ma. Jeśli przy scalaniu zniknie z konfiguracji produkcyjnej, raport ucichnie po cichu – dlatego pominięcie logowane jest na poziomie WARN, a nie INFO. :::

  • Adresatów zmienia NEXT_PUBLIC_MISSING_DOCUMENTS_REPORT_MAILS (lista po przecinku). Wysyłka idzie osobno do każdego adresu, więc odbicie na jednym nie blokuje drugiego; błąd zgłaszany jest dopiero, gdy raport nie dotarł do nikogo.

  • Dane gracza są escapowane przed wstawieniem do HTML – nazwisko z apostrofem czy nawiasem ostrym nie rozjedzie szablonu.

  • Szablon: mails/missing_documents_report.html, wstrzykiwany do bundla przez yarn generate:mail-templates.

Możliwe rozszerzenia

Świadomie odłożone:

  • Trwały zapis powiązania sprzedaży z dokumentem. Tabela sales nie ma kolumny receipt_id, a klucz idempotencji nie jest nigdzie zapisywany. Odzyskiwanie działa więc tylko w obrębie jednego wywołania Server Action – po jego zakończeniu nie ma czym dowiązać paragonu odnalezionego później. Docelowo: kolumna na oid/receipt_id plus uzgadnianie poza żądaniem (waitUntil lub cron).
  • mergeHeaders a wielkość liter. Headers.forEach zwraca klucze małymi literami, więc wywołujący przekazujący content-type zostawi w obiekcie zarówno Content-Type, jak i content-type, a fetch sklei obie wartości. Obecnie żaden wywołujący tego nie robi.
  • Wzorzec DUPLICATE_OID_PATTERN. Dopasowanie do komunikatu o zajętym oid nie zostało potwierdzone na rzeczywistej odpowiedzi 422 z Fakturowni. Niedopasowanie kończy się twardym błędem zamiast uzgodnienia, ale nie tworzy duplikatu (blokuje go oid_unique).