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
| Przycisk | Co robi |
|---|---|
| + / − | Powiększa i pomniejsza dokument. Dokument można przewijać w pionie i w poziomie. |
| Zapisz PDF | Na 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ądarce | Otwiera dokument w zwykłej przeglądarce, poza aplikacją. Przydatne, gdy ktoś chce wydrukować albo przesłać sam link. |
| Zobacz e-Paragon | Pojawia 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ń dodocument.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 atrybutsandboxbezallow-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
| Plik | Rola |
|---|---|
lib/documents/document-preview-store.ts | Mikro-store (useSyncExternalStore) trzymający aktualnie oglądany dokument. Pozwala otworzyć podgląd z dowolnego miejsca bez przekazywania propsów. |
components/documents/document-preview-host.tsx | Zamontowany raz w app/layout.tsx. Dopiero po otwarciu dokumentu ściąga dialog i pdf.js. |
components/documents/document-preview-dialog.tsx | Okno podglądu: nagłówek, viewer, pasek akcji. |
components/documents/pdf-document-viewer.tsx | Renderowanie stron PDF-a przez pdf.js na <canvas>. |
lib/documents/document-file.ts | Budowanie URL-i, pobieranie blobu, zapis pliku (share sheet / download). |
lib/fakturownia/document-links.ts | Publiczne linki tokenowe Fakturowni, Content-Disposition, sanityzacja nazwy pliku. |
lib/invoice-utils.ts | previewInvoice() / previewReceipt() — wejście dla warstwy UI. |
API
GET /api/fakturownia/{invoices|receipts}/{id}/pdf
| Parametr | Wartości | Domyślnie | Opis |
|---|---|---|---|
disposition | inline, attachment | inline | Nagłówek Content-Disposition. |
filename | tekst | faktura-{id} / paragon-{id} | Nazwa pliku; sprowadzana do ASCII przed trafieniem do nagłówka. |
print_option | original, copy, original_and_copy, duplicate | — | Przekazywane 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.
Który link jest e-Paragonem
Fakturownia zwraca dla paragonu dwa różne adresy i tylko jeden z nich jest e-Paragonem:
| Pole | Host | Co otwiera |
|---|---|---|
e_receipt_view_url | …paragony.pl | e-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:
- Zapis —
updatePaymentEReceiptUrliupdateAllPaymentsWithReceiptIdodmawiają zapisania adresu, który nie jest e-Paragonem. - Odczyt —
getEReceiptUrlSecuretraktuje 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. - 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 jeszczeGET /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()— zawszenavigator.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 atrybutdownloadi 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.