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, pojedyncza próba). - 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 przez
findInvoiceByOid. - 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.
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, błąd walidacji, wyczerpany budżet czasu).
Możliwe rozszerzenia
lib/actions/invoice-generation.ts wystawia dokumenty na podstawie paymentId i również może korzystać z createInvoiceIdempotent (klucz np. payment-<tenant>-<paymentId>-receipt). Nie zostało to zmienione w ramach tej poprawki, aby ograniczyć zakres do zgłoszonej ścieżki sprzedaży w sklepiku.
Ś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).