inFakt

Potok fakturowania

Od zdarzenia do gotowej faktury: bramka pełnej zapłaty, pięć powodów pominięcia, trwała maszyna stanów, okno awarii, którego nikt nie ponawia, i jak działa backoff.

Zdarzenie startowe tylko dopisuje wiersz do kolejki. Wszystko, co decyduje, czy faktura naprawdę powstanie, dzieje się później, w workerze, który przy każdym tiku czyta zamówienie na nowo.

Ten podział jest sednem. payment.captured odpala się raz na każde pobranie płatności, a zamówienie można pobrać w częściach. Fakturowanie przy pierwszym pobraniu wystawiłoby dokument na pełną kwotę zamówienia przeciwko zapłacie częściowej.

Od zdarzenia do zamówienia

Obsługiwane są dwa zdarzenia i niosą różne dane:

ZdarzenieCo jest w idJak dojść do zamówienia
order.placedzamówieniewprost
payment.captured (domyślne)płatnośćskok po grafie przez payment_collection.order

Domyślne jest payment.captured, bo Medusa nie ma zdarzenia order.paid, a faktura musi stwierdzać zapłatę, która nastąpiła. order.placed jest dla sklepów fakturujących w momencie złożenia zamówienia, na przykład B2B z terminem płatności, i jest bezpieczne: bramka pełnej zapłaty i tak przytrzyma wiersz, dopóki pieniądze nie dojdą. Zdarzenie decyduje wyłącznie o tym, kiedy wiersz powstaje.

Płatność, dla której nie da się ustalić zamówienia, zwraca null zamiast rzucać wyjątkiem. Kolekcja płatności dla koszyka, który nigdy nie stał się zamówieniem, albo podpięta pod zwrot czy wymianę, to normalny i przewidziany kształt danych. Wyjątek sprawiłby, że Medusa ponawiałaby to zdarzenie w nieskończoność z powodu czegoś, co błędem nie jest.

Bramki sprawdzane przy każdym tiku

Zanim ruszy jakikolwiek krok, worker czyta zamówienie na nowo i sprawdza pięć rzeczy. Każda kończy się wierszem skipped z podanym powodem, nie cichym zignorowaniem.

  1. Zafakturowane gdzie indziej. Jeśli order.metadata.invoice_number jest ustawione, zamówienie zostało zafakturowane poza tym potokiem i jest pomijane. Jeśli wiersz dodatkowo ma już invoice_uuid z tego potoku, to konflikt, który rozstrzyga człowiek, a nie wiersz po cichu wybierający stronę.
  2. Sprzed daty startowej. Przy ustawionym startDate zamówienie złożone wcześniej jest pomijane. Porównanie idzie po warszawskich dniach kalendarzowych, zgodnie z tym, jak data trafia na fakturę. Porównywanie surowych znaczników czasu ustawiłoby zamówienie złożone o 01:00 czasu warszawskiego po niewłaściwej stronie granicy.
  3. Zła waluta. Zamówienie w innej walucie niż skonfigurowana jest pomijane.
  4. Anulowane. Anulowane przed zafakturowaniem to pominięcie. Anulowane po wystawieniu faktury trafia do needs_review: faktura korygująca to dokument prawny rządzący się własnymi zasadami i wtyczka nie wystawia go z własnej inicjatywy.
  5. Nieopłacone w całości. Wiersz jest odkładany i sprawdzany ponownie za 30 minut. Jeśli zamówienie przestaje być opłacone po wystawieniu faktury, to needs_review, a nie nieskończone czekanie na ponowną zapłatę.

Bramka pełnej zapłaty

Pobrania sumują się po wszystkich kolekcjach płatności, pomniejszone o zwroty. Preferowane jest captured_amount samej kolekcji, a gdy tego agregatu brakuje, sumowane są jej pojedyncze płatności. Płatności anulowane są pomijane, bo anulowana płatność potrafi mieć niezerowe captured_amount sprzed anulowania.

Wszystko porównuje się w całkowitych jednostkach minorowych. Porównywanie liczb dziesiętnych prosi się o klasyczne zawieszenie na 133.44 !== 133.44000000000001, gdzie zamówienie jest opłacone w całości, a potok czeka na nie w nieskończoność.

Każda domyślna wartość w tym sumowaniu przesuwa odpowiedź od stanu „opłacone": brak zwrotu naprawdę oznacza brak zwrotu, a nieczytelne pobranie liczy się jako zero. Najgorszy przypadek to jeszcze jeden odłożony tik, nigdy faktura wystawiona przeciwko zapłacie, której nie było. Jedyna wartość, która nigdy nie dostaje wartości domyślnej, to kwota zamówienia. Gdy jej nie da się odczytać, bramka odmawia wprost.

Maszyna stanów

Kolejny krok wynika wyłącznie z tego, które kolumny są jeszcze puste:

  invoice_uuid puste?  -> task_reference ustawione?    -> resolve-create-task
                       -> submit_started_at ustawione? -> OKNO AWARII (odmowa)
                       -> w przeciwnym razie           -> submit-create
  invoice_number puste?                                -> fetch-invoice-number
  ksef_required i brak ksef_number?                    -> ksef_sent_at ? poll-ksef : send-to-ksef
  emitEvent i brak event_emitted_at?                   -> emit-event
  nieoznaczona, nieprzyjęta, niepotwierdzona?          -> confirm-paid
  w przeciwnym razie                                   -> complete

Nie ma licznika kroków ani postępu trzymanego w pamięci i właśnie to sprawia, że awaria w dowolnej chwili jest odwracalna. Czyste reguły siedzą w src/lib/invoicing/state-machine.ts, bez bazy i bez klienta HTTP, więc własności odpornościowe da się przetestować jednostkowo.

Oznaczenie faktury jako zapłaconej

Krok ostatni i nie przez przypadek. POST /async/invoices/{uuid}/paid.json działa asynchronicznie - HTTP 201 znaczy „zadanie przyjęte", a nie „faktura zapłacona" - a status, który z niego wynika, to jedno pole, w którym liczy się ostatni zapis (draft, sent, printed, paid). Nadpisze go każda późniejsza operacja na dokumencie, choćby zwykłe pobranie PDF. Dlatego oznaczenie jest odczytywane z powrotem, a nie brane na wiarę:

  1. Leci markPaid. Ten endpoint nie ma pola z kwotą, a allow_correction nie idzie tam nigdy - zaksięgowanie korekty to decyzja właściciela konta, nie pluginu.
  2. paid_marked_at zapisuje się raz, przy pierwszej próbie, nawet jeśli samo wywołanie się nie udało. To ono pilnuje, żeby krok nie wykonał się drugi raz, i dlatego nigdy nie jest nadpisywane.
  3. Faktura zostaje odczytana ponownie, a czytane jest paid_date. To pole zapisuje endpoint oznaczania i przeżywa każdą późniejszą operację na dokumencie - czego status właśnie nie robi. paid_date ustawia paid_confirmed_at oraz settled_at.
  4. Bez potwierdzenia wiersz i tak się domyka - z ostrzeżeniem wskazującym uuid faktury i zamówienie, a widget zamówienia pokazuje „brak potwierdzenia". Nie ma tu żadnej pętli ponowień: cokolwiek nadpisało oznaczenie za pierwszym razem, nadpisze je i za drugim, więc ponawianie mogłoby tylko wstrzymać domknięcie wystawionej i wysłanej do KSeF faktury dla sygnału, który i tak nigdy się nie ustabilizuje. Rozliczeniem zajmuje się dalej uzgadnianie rozliczeń, we własnym cyklu.

status świadomie nie jest tu sygnałem. Produkcyjna faktura 2/09/2026 została oznaczona o 12:40:03 i odczytana trzy sekundy później jako status: "sent" - to nasz własny załącznik do Allegro pobrał PDF, a pobranie PDF przestawia status. Jej paid_date pozostało nietknięte. Czytana przez status faktura rozliczona co do grosza raportuje się jako niepotwierdzona; czytana przez paid_date - jako to, czym jest. paid_price nie jest lepsze w drugą stronę: faktura 9/08/2026 ma równocześnie status: "paid" i paid_price: 0.

Po ustawieniu paid_confirmed_at płatność nie jest już sprawdzana. Pobranie PDF przez człowieka przestawia status w inFakcie na „printed" i to nie może znaczyć, że zapłata się cofnęła. Faktura przyjęta (adopted) nie jest oznaczana w ogóle

  • to nie jest dokument wystawiony przez ten potok.

Okno awarii

submit_started_at zapisuje się przed wywołaniem tworzącym fakturę. Jeśli worker zastanie później tę kolumnę wypełnioną bez task_reference, wywołanie mogło dojść do inFaktu i wtedy się zatrzymuje:

a previous inFakt create attempt may have gone through without a stored task reference - check inFakt for a stray invoice, then adopt it with link-manually or clear the row

Wiersz trafia do needs_review i nigdy nie jest ponawiany automatycznie. Człowiek sprawdza inFakt i albo przejmuje zabłąkaną fakturę, albo czyści znacznik. Takie wiersze API rejestru oznacza polem in_crash_window, żeby panel mógł wyłączyć przycisk ponowienia, zamiast pozwalać operatorowi dowiadywać się o problemie z treści błędu.

Automatyczne ponowienie tego jednego wywołania to sytuacja, której ta konstrukcja odmawia. Cała reszta jest ponawiana, bo cała reszta jest albo idempotentna, albo obserwowalna.

Wyniki, ponowienia i backoff

Krok kończy się rzuceniem jednego z trzech sygnałów sterujących albo prawdziwym błędem:

SygnałZnaczenieZużywa próbę?
defernie skończone, ale to nie porażka - trwa przetwarzanie albo brak zapłatyNie
reviewkoniec, potrzebny człowieknie dotyczy
skipświadomie niefakturowane, z powodemNie
rzucony błądponowienie z backoffemTak

To, że defer nie zużywa próby, jest celowe: powolny system zewnętrzny nie może przepalić budżetu ponowień. Wiersz odłożony sto razy nie wydał nic.

Czekanie na dane to nie weryfikacja

W zamówieniu z marketplace dane nabywcy przychodzą razem z płatnością, więc pierwsza próba potrafi wystartować, zanim one w ogóle istnieją. Wiersz, który to ujawnił, trafił do kolejki o 12:36:24, o 12:36:25 został zaparkowany z powodu braku ulicy, miasta i kodu pocztowego - a prawdziwy adres dopisano 16 sekund później.

Dlatego powód „niekompletny adres", i wyłącznie ten jeden, odkłada wiersz zamiast go parkować. Granicą jest zegar, nie licznik prób (odłożenie żadnej nie zużywa): 60 minut od created_at wiersza, a po tym czasie robi się z tego ten sam needs_review co zawsze, z tym samym komunikatem i dopiskiem, czego dowiodło czekanie. Każda inna odmowa budowania dokumentu - nazwa firmy, która trafiłaby na fakturę zniekształcona, NIP, który się nie normalizuje, numer VAT niepotwierdzony w VIES - parkuje natychmiast, w oknie dokładnie tak samo jak poza nim.

Odłożony wiersz niesie defer_reason, który widget zamówienia pokazuje obok czasu kolejnego sprawdzenia, więc „Oczekuje" mówi wreszcie, na co. Kolumna jest czyszczona w chwili, gdy wiersz rusza dalej. Odłożenie nie podnosi żadnego powiadomienia w panelu ani alertu - te zostają dla prawdziwego needs_review.

allegro.order.billing_ready (z @zanreal/medusa-allegro) jest subskrybowane jako budzik, nie jako wyzwalacz: popycha wiersz, który już czeka na ogłaszany adres, i nigdy nie dodaje nowego do kolejki.

Ponowienia odsuwają się według 5 min * 2 ** próby, z sufitem 6 godzin, więc pierwsze czeka już 10 minut. Awarie, które w ogóle warto ponawiać, czyli limity zapytań i przestoje inFaktu, nie mijają w pięć. Po 8 próbach wiersz idzie do needs_review, bo nieskończona pętla ponowień na trwale zepsutym wierszu wygląda na każdym wykresie identycznie jak działający potok.

Część statusów HTTP kończy sprawę od razu, bez ponawiania: 400, 403, 404, 405, 409, 422. 409 jest na tej liście, bo inFakt używa go w znaczeniu „to już się wydarzyło", czego ponowienie nie poprawi. Świadomie nie ma tam 429 ani 5xx, bo to dokładnie te przypadki, dla których backoff istnieje.

last_error jest przycinane do 300 znaków i nie niesie danych osobowych nabywcy. Zrzut błędów walidacji z inFaktu potrafi mieć kilka kilobajtów.

Częstotliwości

CoOdstęp
Harmonogram workera*/5 * * * *, nadpisywany przez INFAKT_WORKER_CRON. Siatka bezpieczeństwa - opłacone zamówienie jest fakturowane natychmiast po payment.captured.
Sprawdzenie, gdy inFakt albo KSeF wciąż przetwarzają2 minuty
Dojeżdżanie KSeF do stanu końcowego w jednym przebiegu5 s, podwajane do sufitu 30 s, łącznie do 150 s na cały przebieg
Oczekiwanie na dane nabywcy, liczone od created_at60 minut
Sprawdzenie, gdy zamówienie nie jest jeszcze opłacone30 minut
Potwierdzanie oznaczenia zapłatyJeden odczyt zwrotny, w tym samym przebiegu. Bez ponowień - patrz wyżej
Uzgadnianie rozliczeń17 * * * *, nadpisywane przez INFAKT_SETTLEMENT_CRON. Osobne zadanie na osobnych kolumnach
Ponowny odczyt faktury, której rozliczenie wciąż się nie zgadza6 godzin
Pierwsze ponowienie po błędzie10 minut, podwajane do sufitu 6 godzin

Harmonogram workera jest zmienną środowiskową, a nie opcją wtyczki, bo Medusa czyta config.schedule zadania cyklicznego w momencie ładowania wtyczki, zanim istnieje kontener i rozwiązane opcje.

Po wystawieniu faktury wtyczka emituje infakt.invoice.issued, chyba że emitIssuedEvent ustawiono na false. Dzięki temu inna wtyczka może zareagować, na przykład dopiąć PDF do zamówienia z marketplace'u.

Spis treści