Skip to main content

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, pojedyncza próba).
  3. Gdy dokument istnieje – zwracany jest istniejący dokument.
  4. Gdy dokumentu nie ma, a błąd był przejściowy – ponowna próba utworzenia, a po niej ostatnia weryfikacja przez findInvoiceByOid.
  5. 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.

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, 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 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).