Skip to main content

Podgląd faktur i paragonów

Każda faktura i każdy paragon otwiera się teraz w podglądzie wewnątrz aplikacji — zamiast próbować pobrać plik PDF na dysk. Powód jest prosty: na telefonie pobieranie w ogóle nie działało.

👤 Instrukcja dla użytkownika

Jak obejrzeć dokument

Wszędzie tam, gdzie widać numer faktury lub paragonu — na liście płatności, w szczegółach rezerwacji, w historii płatności klienta, w rozliczeniu dzielonym — kliknięcie numeru otwiera podgląd dokumentu.

Podgląd zajmuje cały ekran telefonu (na komputerze — okno na środku) i pokazuje prawdziwy dokument z Fakturowni, strona po stronie.

Co można zrobić w podglądzie

PrzyciskCo robi
+ / −Powiększa i pomniejsza dokument. Dokument można przewijać w pionie i w poziomie.
Zapisz PDFNa telefonie otwiera systemowe okno udostępniania — stamtąd wybiera się „Zapisz w Plikach", wysyłkę mailem albo przesłanie na komunikator. Na komputerze zapisuje plik do pobranych.
Otwórz w przeglądarceOtwiera dokument w zwykłej przeglądarce, poza aplikacją. Przydatne, gdy ktoś chce wydrukować albo przesłać sam link.
Zobacz e-ParagonPojawia się tylko przy paragonach, do których Fakturownia wystawiła już e-Paragon.

Dlaczego „Zapisz" wygląda inaczej na telefonie

Aplikacja mobilna AcePark to strona internetowa opakowana w natywną powłokę (WKWebView na iOS). Taka powłoka nie ma pojęcia „folderu pobranych" — kliknięcie zwykłego linku do pobrania nie robiło nic i to była przyczyna zgłoszenia, że „paragon się nie pobiera". Systemowe okno udostępniania jest na telefonie właściwym odpowiednikiem pobierania i pozwala zapisać plik tam, gdzie użytkownik chce.

Gdy dokument się nie wczyta

Podgląd pokaże komunikat z przyciskami Spróbuj ponownie oraz Otwórz w przeglądarce. Drugi z nich korzysta z publicznego linku Fakturowni, więc działa nawet wtedy, gdy problem leży po stronie sesji w aplikacji.

🛠 Dokumentacja techniczna

e-Paragon w ramce

Strona e-paragonu (https://{tenant}.paragony.pl/{token}) też odmawia osadzenia — x-frame-options: SAMEORIGIN, sprawdzone curl -I. Nadaje się natomiast do proxowania i to robi GET /api/fakturownia/receipts/{id}/e-receipt-view: pobiera ją po stronie serwera i wystawia z naszego origin, więc ramka działa.

Trzy rzeczy, które to umożliwiają:

  • Strona jest statyczna — jeden zewnętrzny skrypt (Alpine), zero inline JS, zero fetch/XHR, żadnych odwołań do document.cookie, localStorage, window.parent.
  • Wszystkie zasoby (CSS, fonty, obrazki) mają adresy absolutne, więc po przeniesieniu na inny origin nadal się ładują — nie trzeba wstrzykiwać <base>.
  • Odpowiedź niesie Content-Security-Policy: sandbox allow-scripts allow-popups, a ramka dodatkowo ma atrybut sandbox bez allow-same-origin. Dokument dostaje więc nieprzezroczysty origin i nie sięgnie do ciasteczek ani storage aplikacji, mimo że serwuje go nasza domena.

Adres e-paragonu bierze się z odpowiedzi Fakturowni, nie od przeglądarki, ale i tak przechodzi przez isAllowedEReceiptHost() — host musi być paragony.pl lub jego subdomeną, po HTTPS. To zamyka drogę do SSRF, gdyby kiedykolwiek dało się podstawić inny adres.

Dlaczego nie <iframe> z podglądem Fakturowni

Pierwszym pomysłem było osadzenie strony podglądu Fakturowni (https://{domena}.fakturownia.pl/invoice/{token}) w ramce. Nie da się: Fakturownia zwraca nagłówek x-frame-options: SAMEORIGIN, który blokuje osadzenie na obcej domenie. Publiczny link jest więc używany wyłącznie jako cel „otwórz na zewnątrz", nigdy jako źródło ramki.

Osadzenie samego PDF-a w <iframe>/<embed> też odpada: przeglądarki mobilne obsługują to niespójnie (Chrome na Androidzie w ramce PDF-a nie renderuje). Dlatego dokument jest rasterizowany po stronie klienta.

Przepływ

Elementy

PlikRola
lib/documents/document-preview-store.tsMikro-store (useSyncExternalStore) trzymający aktualnie oglądany dokument. Pozwala otworzyć podgląd z dowolnego miejsca bez przekazywania propsów.
components/documents/document-preview-host.tsxZamontowany raz w app/layout.tsx. Dopiero po otwarciu dokumentu ściąga dialog i pdf.js.
components/documents/document-preview-dialog.tsxOkno podglądu: nagłówek, viewer, pasek akcji.
components/documents/pdf-document-viewer.tsxRenderowanie stron PDF-a przez pdf.js na <canvas>.
lib/documents/document-file.tsBudowanie URL-i, pobieranie blobu, zapis pliku (share sheet / download).
lib/fakturownia/document-links.tsPubliczne linki tokenowe Fakturowni, Content-Disposition, sanityzacja nazwy pliku.
lib/invoice-utils.tspreviewInvoice() / previewReceipt() — wejście dla warstwy UI.

API

GET /api/fakturownia/{invoices|receipts}/{id}/pdf

ParametrWartościDomyślnieOpis
dispositioninline, attachmentinlineNagłówek Content-Disposition.
filenametekstfaktura-{id} / paragon-{id}Nazwa pliku; sprowadzana do ASCII przed trafieniem do nagłówka.
print_optionoriginal, copy, original_and_copy, duplicatePrzekazywane do Fakturowni.

Odpowiedź: application/pdf, Cache-Control: private, max-age=300.

GET /api/fakturownia/{invoices|receipts}/{id}/public-link

{
"number": "FV 12/08/2026",
"eReceiptViewUrl": "https://…/e-receipt/…",
"previewUrl": "https://acepark.fakturownia.pl/invoice/HBO3Npx2OzSW79RQL7XV2",
"pdfUrl": "https://acepark.fakturownia.pl/invoice/HBO3Npx2OzSW79RQL7XV2.pdf",
"inlinePdfUrl": "https://acepark.fakturownia.pl/invoice/HBO3Npx2OzSW79RQL7XV2.pdf?inline=yes"
}

Oba endpointy przechodzą przez withInvoiceAuth / withReceiptAuth, więc klient widzi wyłącznie własne dokumenty. api_token Fakturowni nigdy nie opuszcza serwera — publiczne linki opierają się na tokenie dokumentu (pole token w odpowiedzi /invoices/{id}.json), zgodnie z rozdziałem „Link do podglądu faktury i pobieranie do PDF" dokumentacji Fakturowni.

e_receipt_view_url jest czytany na świeżo z Fakturowni, a nie tylko z bazy. E-Paragon bywa wystawiony z opóźnieniem — gdy drukarka fiskalna była offline, link pojawia się dopiero po jej ponownym połączeniu. Odczyt na żądanie pokazuje przycisk „Zobacz e-Paragon" także wtedy, gdy w momencie rozliczenia płatności linku jeszcze nie było.

Fakturownia zwraca dla paragonu dwa różne adresy i tylko jeden z nich jest e-Paragonem:

PoleHostCo otwiera
e_receipt_view_url…paragony.ple-Paragon fiskalny — to, co klient ma zobaczyć
view_url…fakturownia.net/f/…stronę dokumentu w Fakturowni, z własnym przyciskiem „Zobacz e-Paragon"

Wcześniej, gdy e_receipt_view_url nie było jeszcze gotowe, processOnlinePaymentReceipt zapisywał do kolumny payment.e_receipt_view_url adres view_url. Klient klikający „Zobacz e-Paragon" trafiał więc na stronę dokumentu i musiał kliknąć drugi raz, żeby zobaczyć sam paragon.

isEReceiptViewUrl (lib/fakturownia/e-receipt-url.ts) rozstrzyga to po hoście (paragony.pl lub jego subdomena) i jest wpięte w trzech miejscach:

  1. ZapisupdatePaymentEReceiptUrl i updateAllPaymentsWithReceiptId odmawiają zapisania adresu, który nie jest e-Paragonem.
  2. OdczytgetEReceiptUrlSecure traktuje zły adres w bazie jak brak adresu: dopytuje Fakturownię i nadpisuje rekord poprawnym linkiem. Stare płatności naprawiają się więc same przy pierwszym kliknięciu.
  3. Interfejs — przyciski i linki („Zobacz e-Paragon", historia płatności klienta, GameDetailsDialog) pokazują się tylko dla prawidłowego adresu. Tabela transakcji, zanim zejdzie do pobrania PDF-a, dopytuje jeszcze GET /api/payments/{id}/e-receipt — dzięki temu wiersz wyczyszczony przez migrację nadal otwiera e-Paragon zamiast pobierać plik.

Migracja 0242_clear_non_e_receipt_links.sql czyści historyczne wartości w payment i linked_payment, żeby nie czekać na odczyt każdego rekordu.

Zasoby pdf.js

pdfjs-dist jest importowany dynamicznie (legacy/build/pdf.mjs — wersja transpilowana, z polyfillami dla starszych WebKitów). Worker i fonty standardowe są kopiowane z node_modules do public/pdf/ przez scripts/copy-pdf-worker.mjs.

Kopiowanie jest wpięte w dwa miejsca: postinstall oraz next.config.mjs. Drugie jest istotne, bo buildy wariantów wołają next build bezpośrednio i omijają prebuild — a brak workera nie wywala builda, tylko psuje podgląd dopiero w runtime. Skrypt trzyma znacznik public/pdf/.version i przy zgodnej wersji nie robi nic.

Katalog public/pdf/ jest w .gitignore. Jeśli import pdfjs-dist nagle przestaje się rozwiązywać, pierwszym krokiem jest yarn install — pakiet potrafi zniknąć z node_modules po operacjach na gałęziach, które ruszają package.json.

Zapis pliku

Trzy osobne funkcje w lib/documents/document-file.ts:

  • downloadDocumentFile() — zawsze <a download>, bez pytania.
  • shareDocumentFile() — zawsze navigator.share({ files }); anulowanie okna przez użytkownika nie jest traktowane jako błąd.
  • saveDocumentFile() — to, co siedzi pod przyciskiem „Zapisz": normalnie pobranie, a w powłoce natywnej (Capacitor.isNativePlatform()) okno zapisu systemowego, bo WKWebView ignoruje atrybut download i zwykłe pobranie nie zrobiłoby nic.

Odwołanie object URL-a jest odroczone o 60 sekund: Safari przerywa pobieranie, jeśli revokeObjectURL wywołać w tym samym ticku co click(). To była druga przyczyna, dla której stare pobieranie milczało.