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:
- 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.
- 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ć:
- Nie powtarzaj sprzedaży w sklepiku – sprzedaż i stan magazynowy są już zapisane.
- Odczekaj chwilę i sprawdź listę dokumentów w Fakturowni.
- 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.
| Element | Opis |
|---|---|
FakturowniaTimeoutError | Typowany błąd przekroczenia limitu czasu; niesie endpoint, method i timeoutMs. |
FakturowniaHttpError | Typowany błąd odpowiedzi HTTP; niesie status, statusText oraz surowe responseText. |
isFakturowniaTimeoutError | Rozpoznaje timeout – używane do wyświetlenia dedykowanego komunikatu w UI. |
isFakturowniaTransientError | Klasyfikuje błąd jako przejściowy: timeout, zerwane połączenie oraz statusy 408, 425, 429, 500, 502, 503, 504. |
getFakturowniaErrorText | Zwraca treść odpowiedzi Fakturowni (a nie pełny komunikat z ciałem żądania). |
withFakturowniaRetry | Do 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:
| Opcja | Domyślnie | Znaczenie |
|---|---|---|
timeoutMs | 15_000 | Limit czasu pojedynczej próby. |
retry | true dla GET, false dla reszty | Czy żą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
AcceptiContent-Typebyły gubione, gdy wywołujący przekazał własneheaders(spread...optionsnadpisywał scalony obiekt), signalprzekazany 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:
- POST
/invoices.jsonzoid = idempotencyKeyorazoid_unique: 'yes'(bez automatycznych ponowień). - Przy błędzie przejściowym lub odrzuceniu z powodu zajętego
oid–findInvoiceByOid(oid)(GET,period: 'last_30_days'). - 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óregooidnadal blokuje ponowne wystawienie. - Gdy dokument istnieje – zwracany jest istniejący dokument.
- 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).
- 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
createInvoiceIdempotentz kluczemsale-${tenantId}-${saleId}, - fiskalizacja (
/invoices/fiscal_print) jest wywoływana zretry: trueitimeoutMs: 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ż zafiskalizowanyjest interpretowana jako sukces (isAlreadyFiscalizedError), a nie błąd, - timeout kończy się komunikatem
errors.fakturowniaTimeoutzamiast 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żka | Klucz |
|---|---|
| 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ści | payment-<tenant>-<klucz grupy>-bulk-nominal-invoice |
| Paragon za grupę płatności | payment-<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
createInvoiceIdempotentdostajebudgetMsrówne temu, co zostało (nie więcej niż domyślne 30 s), generateInvoiceFromPaymentigenerateBulkInvoiceFromPaymentsdzielą 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
idtrafiają 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, politykaretrydlaGET/POST, maskowanie tokenu API oraz wszystkie ścieżkicreateInvoiceIdempotent(reużycie dokumentu, ponowne wystawienie, duplikatoid, 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 wymuszenieprintReceipt: 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 NULLjest stosowany zarówno w endpointcie, jak i wprocessPaymentStatusChange, więc płatność z dokumentem nigdy nie dostanie drugiego. - Daty dokumentu (
issue_date,sell_date,paid_date) pochodzą zpaid_atpłatności, a nie z chwili odzyskiwania – paragon wystawiony po fakcie ma datę faktycznej zapłaty. printReceiptjest wymuszone nafalse, 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ę 0 –
generateBulkInvoiceFromPaymentsodpowiada dla nichNO_DOCUMENT_REQUIREDi 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ścipaid_walletw 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.
executeRecurringTransferzatrzymuje pieniądze ze starego cyklu (status = 'refunded',funds_retained = 1) i zakłada nowe płatności ze statusempaid, choć do kasy nie wpłynął ani grosz. Te nowe wiersze przejmująreceipt_id/receipt_number/invoice_id/invoice_number/e_receipt_view_urlpo ź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()wworker-crons.tsodrzuca drugą, dokładnie jak dla pary porannej. Godzina siedzi wCLOSING_HOUR(lib/cron-schedule.ts). -
Klucz crona
missing_documents_report– da się go wyłączyć w panelu bez deployu (migracja0232). -
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]wwrangler.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
mainREPORT_ENVIRONMENTnależy do produkcyjnej wersjiwrangler.admin.toml, czyli tej z gałęzimain. Wersja zdevelopcelowo 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 przezyarn generate:mail-templates.
Możliwe rozszerzenia
Świadomie odłożone:
- Trwały zapis powiązania sprzedaży z dokumentem. Tabela
salesnie ma kolumnyreceipt_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 naoid/receipt_idplus uzgadnianie poza żądaniem (waitUntillub cron). mergeHeadersa wielkość liter.Headers.forEachzwraca klucze małymi literami, więc wywołujący przekazującycontent-typezostawi w obiekcie zarównoContent-Type, jak icontent-type, afetchsklei obie wartości. Obecnie żaden wywołujący tego nie robi.- Wzorzec
DUPLICATE_OID_PATTERN. Dopasowanie do komunikatu o zajętymoidnie zostało potwierdzone na rzeczywistej odpowiedzi 422 z Fakturowni. Niedopasowanie kończy się twardym błędem zamiast uzgodnienia, ale nie tworzy duplikatu (blokuje gooid_unique).