feat(115): wystawianie faktury z zamowienia (lokalne + delegowane Fakturownia)

Phase 115 complete (vertical slice "zamowienie z NIP -> faktura PDF"):
- Task 1: InvoiceRepository + InvoiceService (dual-flow orchestrator) +
  InvoiceIssueException + FakturowniaApiClient::createInvoice + buildPdfUrl
- Task 2: InvoiceController + OrdersController::toggleInvoiceRequested +
  OrdersRepository::setInvoiceRequested + auto-import invoice_requested z
  Allegro (invoice.required) i shopPRO (5-key flexible parser) + show.php
  (toggle w zakladce Platnosci + warunkowy przycisk Wystaw fakture)
- Task 3: Lista wystawionych /settings/accounting/invoices/issued z filtrami
  + invoice_preview + invoice_pdf Dompdf template + hub link
- Task 3b (dodany): NIP lookup przez MF Biala Lista (publiczne API, bez
  rejestracji) — MfWhitelistApiClient w src/Core/Http/ + /api/nip/lookup +
  przycisk "Pobierz z GUS" w formularzu

Auto-fixes podczas smoke testu (5):
- GUS endpoint Fakturowni nie istnial (HTML 404 -> "json is not valid");
  switch na MF Biala Liste
- PHP 8.5 curl_close() deprecation wycieka HTML przed JSON; usuniete z
  MfWhitelistApiClient i FakturowniaApiClient (3 miejsca)
- Fakturownia 422 payment_to_kind_days (nieistniejace pole) -> usuniete
- Generic "error" w 422 -> parser plaskuje errors: {pole: [...]} +
  error_log z 1000 znakow raw body
- Fakturownia security odrzuca seller_*/department_id jako "create new
  department"; usuniete z payloadu (Fakturownia uzywa danych konta)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 23:34:50 +02:00
parent 6129042ff6
commit 33ee1a1cf5
28 changed files with 3228 additions and 45 deletions

View File

@@ -13,7 +13,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value |
|-----------|-------|
| Version | 3.7.0-dev |
| Status | v3.7 in progress — Phases 113 (Fakturownia Foundation) + 114 (Accounting Configs Refactor) shipped |
| Status | v3.7 in progress — Phases 113 (Fakturownia Foundation) + 114 (Accounting Configs Refactor) + 115 (Wystawianie faktury z zamowienia) shipped |
| Last Updated | 2026-05-10 |
## Requirements
@@ -118,6 +118,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Re-import istniejacego zamowienia jest delta-only: skip dla pozycji/adresow/notatek (stabilne `order_items.id`, ochrona `project_generated`), zawezony `updateOrderDelta()`, propagacja anulowania ze zrodla, identical-payload no-op guard (case #882) — Phase 112
- [x] Fundament v3.7 Invoices: tabele `invoices`, `invoice_configs`, `invoice_number_counters`, `fakturownia_integration_settings` + `orders.invoice_requested`; CRUD kont Fakturowni z testem polaczenia API (`/settings/integrations/fakturownia`); karta w hubie integracji — Phase 113
- [x] Ksiegowosc: refaktor `/settings/accounting` na hub-rozdroze + osobne podstrony `/receipts` i `/invoices` + edycja na osobnym widoku; pelen CRUD `invoice_configs` z opcja delegacji do Fakturowni (conditional integration_id, serwerowa walidacja); seed `Domyslny VAT`; globalny modul `confirm-delete.js` — Phase 114
- [x] Wystawianie faktury z zamowienia: toggle `orders.invoice_requested` w zakladce Platnosci + auto-set z importu (Allegro `invoice.required` / shopPRO 5-key parser); formularz z auto-fillem NIP przez MF Biala Liste (publiczne API); dual flow lokalny (Dompdf + atomowy `invoice_number_counters`) / delegowany (POST do Fakturowni przed INSERT, redirect 302 do natywnego PDF); lista `/settings/accounting/invoices/issued` z filtrami; snapshot pattern w `invoices` JSON; PHP 8.5-compatible (curl_close removed) — Phase 115
### Deferred
@@ -126,7 +127,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
### Active (In Progress)
- [ ] v3.7 Invoices — wystawianie faktur dla klientow z NIP przez integracje z Fakturownia (multi-account, lokalna numeracja z opcja delegacji, rozdzielenie przyciskow paragon/faktura, osobne podstrony edycji configs). Phase 113 shipped; kolejne fazy w planowaniu.
- [ ] v3.7 Invoices — wystawianie faktur dla klientow z NIP przez integracje z Fakturownia (multi-account, lokalna numeracja z opcja delegacji, rozdzielenie przyciskow paragon/faktura, osobne podstrony edycji configs). Phases 113 + 114 + 115 shipped; ewentualne kolejne fazy (np. eksport XLSX, invoice.created event, idempotencja Fakturowni) w kolejce.
### Planned (Next)
@@ -214,6 +215,12 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Legacy aliasy starych endpointow `/settings/accounting/save\|toggle\|delete` jako duplicate routes | Brak inwentaryzacji zewnetrznych referencji/bookmarkow - zero kosztu utrzymania, pelna wsteczna kompatybilnosc | 2026-05-10 | Active |
| `OrderProAlerts.confirm` to options-object API (`{title, message, onConfirm, danger, confirmLabel}`), nie pozycyjne argumenty | Pozycyjne wywolanie cicho fail'uje - callback ginie. Bug znaleziony w Phase 114-01 podczas smoke testu user. Pattern dla wszystkich przyszlych confirm dialogow | 2026-05-10 | Active |
| Globalny `confirm-delete.js` z `data-confirm-bound='1'` idempotent guard | Stare widoki maja inline scripts robiace to samo - guard zapobiega podwojnemu bindowi gdy modul globalny widzi juz-bound buttony. Mozna stopniowo migrowac stare widoki | 2026-05-10 | Active |
| Wystawianie faktury delegowanej do Fakturowni: POST PRZED INSERT lokalnym | Brak orphan rows w `invoices` gdy API padnie. On success zapis `external_invoice_id`/`external_pdf_url`/`invoice_number` z odpowiedzi Fakturowni. Trade-off: brak idempotencji przy double-POST -> INVOICE-IDEMP-115 w todo.md | 2026-05-10 | Active |
| NIP lookup przez MF Biala Liste (publiczne API) zamiast Fakturowni | Fakturownia API NIE MA endpointu GUS (sprawdzone w dokumentacji 2026-05-10). MF Biala Lista jest publiczna (bez rejestracji/klucza), zwraca nazwa+adres+REGON. Klient `MfWhitelistApiClient` w `src/Core/Http/` dostepny dla innych modulow | 2026-05-10 | Active |
| Fakturownia invoice payload: NIE wysylamy `seller_*` ani `department_id` | Konta z podwyzszonym security interpretuja roznice w seller_bank_account jako proba "utworz nowy dzial" -> HTTP 422. Fakturownia uzywa danych konta jako sprzedawca. Lokalny snapshot w `invoices.seller_data_json` zachowany dla audytu | 2026-05-10 | Active |
| PHP 8.5: zakaz `curl_close()` w nowym kodzie | Deprecated od 8.5 (no-op od 8.0). Wycieka HTML `<br/><b>Deprecated</b>...` przed JSON response -> "json is not valid" w fetch().json(). Pattern dla wszystkich httpGet/httpPost helperow w `src/Core/Http/` i `src/Modules/Settings/*ApiClient` | 2026-05-10 | Active |
| Auto-set `orders.invoice_requested` tylko przy `created=true` w imporcie | Delta-only re-import (Phase 112) zachowuje stabilnosc manualnej flagi operatora. Re-import nie nadpisuje stanu lokalnego, w tym manualnego "Klient prosi o fakture" | 2026-05-10 | Active |
| `OrderProAlerts.confirm` ZAWSZE options-object API (`{title, message, onConfirm, danger}`) | Phase 114 ustalil. Phase 115 uzyl w invoice_form.php. Pozycyjne wywolanie cicho fail'uje - callback ginie. Pattern obowiazuje dla wszystkich nowych confirm dialogow | 2026-05-10 | Active |
## Success Metrics
@@ -245,6 +252,6 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-05-10 after Phase 114 (Accounting Configs Refactor) completion; v3.7 Invoices milestone in progress*
*Last updated: 2026-05-10 after Phase 115 (Wystawianie faktury z zamowienia) completion; v3.7 Invoices milestone in progress*

View File

@@ -14,11 +14,13 @@ Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakt
|-------|------|-------|--------|
| 113 | Fakturownia Integration Foundation | 1/1 | Complete (2026-05-10) |
| 114 | Accounting Configs Refactor (hub + osobne podstrony receipts/invoices) | 1/1 | Complete (2026-05-10) |
| 115 | Wystawianie faktury z zamowienia (lokalne + delegacja Fakturownia) | 0/? | Planning |
| 115 | Wystawianie faktury z zamowienia (lokalne + delegacja Fakturownia + NIP lookup MF Biala Lista) | 1/1 | Complete (2026-05-10) |
Planowane kolejne fazy v3.7 (do dokladnego rozplanowania):
- Lista faktur w sekcji Ksiegowosc + podglad/wydruk PDF
- `orders.invoice_requested` w importerach Allegro/shopPRO + toggle w UI zamowienia
Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania):
- Eksport XLSX listy wystawionych faktur (analogicznie do paragonow)
- Idempotencja podwojnego POST do Fakturowni (INVOICE-IDEMP-115)
- Event automatyzacji `invoice.created` (jezeli operator chce wysylac faktury mailem)
- Backfill `curl_close()` w `ShopproIntegrationsRepository` (PHP 8.5 compat, poza zakresem 115)
## Next Milestone
@@ -492,4 +494,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-05-10 - Phase 114 (Accounting Configs Refactor) complete; v3.7 milestone in progress*
*Last updated: 2026-05-10 - Phase 115 (Wystawianie faktury z zamowienia) complete; v3.7 milestone in progress*

View File

@@ -10,14 +10,14 @@ See: .paul/PROJECT.md (updated 2026-05-07)
## Current Position
Milestone: v3.7 Invoices (Fakturownia integration) — In progress
Phase: 115 of TBD (Wystawianie faktury z zamowienia) — Not started
Phase: 116 of TBD (TBD — kandydaci v3.7 lub przejscie na kolejny milestone) — Not started
Plan: pending
Status: Phase 114 complete (transition done); ready to plan Phase 115
Last activity: 2026-05-10 — UNIFY 114-01 complete, Phase 114 closed, transition done
Status: Phase 115 closed; transition done; ready to plan kolejna faze
Last activity: 2026-05-10 — UNIFY 115-01 complete + transition: PROJECT.md/ROADMAP.md/changelog zaktualizowane
Progress:
- Milestone v3.7: [███░░░░░░░] ~30% (Phase 113 + 114 zamkniete; planowane ~5 faz)
- Phase 114: [██████████] 100% — Complete
- Milestone v3.7: [██████░░░░] ~55% (Phase 113 + 114 + 115 zamkniete; kandydaci: XLSX invoices export, INVOICE-IDEMP-115, invoice.created event, curl_close shopPRO backfill)
- Phase 115: [██████████] 100% — Complete
## Loop Position
@@ -26,24 +26,25 @@ Current loop state:
v3.7 milestone:
Phase 113 (Fakturownia Integration Foundation): Complete
Phase 114 (Accounting Configs Refactor): Complete
Phase 115 (Wystawianie faktury z zamowienia): not started
Phase 115 (Wystawianie faktury z zamowienia): Complete
Phase 116 (TBD): not started
```
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Phase 114 closed; ready for Phase 115 plan]
✓ ✓ ✓ [Phase 115 closed; ready for Phase 116 plan]
```
## Session Continuity
Last session: 2026-05-10
Stopped at: Phase 114 transition complete (PROJECT.md + ROADMAP.md updated, SUMMARY zapisany)
Next action: /paul:plan dla Phase 115 (wystawianie faktury z zamowienia, lokalna numeracja + delegacja Fakturownia)
Resume file: .paul/phases/114-accounting-configs-refactor/114-01-SUMMARY.md
Stopped at: Phase 115 transition complete (PROJECT.md + ROADMAP.md updated, SUMMARY zapisany, changelog zaktualizowany)
Next action: /paul:plan dla kolejnej fazy (v3.7 kandydaci: XLSX export listy faktur, INVOICE-IDEMP-115, invoice.created automation event, lub backfill `curl_close()` w shopPRO)
Resume file: .paul/phases/115-invoice-from-order/115-01-SUMMARY.md
## Git State
Last commit: 2382018 feat(113): fakturownia integration foundation
Last commit: 6129042 feat(114): accounting configs refactor + invoice configs CRUD
Branch: main
Feature branches merged: none

View File

@@ -2,6 +2,18 @@
## Co zrobiono
- [Phase 115, Plan 01] Wystawianie faktury z zamowienia — vertical slice "zamowienie z NIP -> faktura PDF" w trybach lokalnym (Dompdf+counter) i delegowanym (POST do Fakturowni + redirect 302 do natywnego PDF)
- Phase 115 Task 1: InvoiceRepository + InvoiceService (dual-flow orchestrator) + InvoiceIssueException + FakturowniaApiClient::createInvoice (POST /invoices.json) + buildPdfUrl
- Phase 115 Task 2: InvoiceController + OrdersController::toggleInvoiceRequested + OrdersRepository::setInvoiceRequested + auto-import flagi z Allegro (`invoice.required`) i shopPRO (flexible 5-key parser) + show.php (toggle w zakladce Platnosci + warunkowy przycisk Wystaw fakture)
- Phase 115 Task 3: Lista wystawionych `/settings/accounting/invoices/issued` z filtrami + invoice_preview + invoice_pdf Dompdf template + hub link
- Phase 115 Task 3b (dodany): NIP lookup przez MF Biala Lista (publiczne API, bez rejestracji) — `MfWhitelistApiClient` + endpoint `/api/nip/lookup` + przycisk "Pobierz z GUS" w formularzu
- Auto-fix: GUS endpoint Fakturowni nie istnial (zwracal HTML 404 -> "json is not valid"); switch na MF Biala Liste
- Auto-fix: PHP 8.5 `curl_close()` deprecation wycieka HTML przed JSON; usuniete z MfWhitelistApiClient + FakturowniaApiClient (3 miejsca)
- Auto-fix: Fakturownia payload nieistniejace `payment_to_kind_days` -> usuniete (sama data `payment_to` wystarcza)
- Auto-fix: Generic `error` w 422 -> parser plaskuje `errors: {pole: [...]}` na czytelne komunikaty + error_log z raw body
- Auto-fix: Fakturownia security odrzuca `seller_*`/`department_id` jako "create new department" -> usuniete z payloadu (Fakturownia uzywa danych konta)
- Phase 115 transition: pending (STATE.md update, PROJECT.md, ROADMAP.md, git commit)
- [Phase 114, Plan 01] Accounting Configs Refactor — `/settings/accounting` jako hub-rozdroze, osobne podstrony Paragony/Faktury, pelen CRUD `invoice_configs` z delegacja do Fakturowni
- Phase 114 Task 1: migracja seed `Domyslny VAT` (idempotentna NOT EXISTS) + `InvoiceConfigRepository` z walidacja delegacji
- Phase 114 Task 2: `InvoiceConfigController` + widoki listy/edycji + `invoice-config-form.js` (conditional integration_id select) + 6 routes
@@ -21,6 +33,24 @@
## Zmienione pliki
- `src/Modules/Accounting/InvoiceRepository.php`
- `src/Modules/Accounting/InvoiceService.php`
- `src/Modules/Accounting/InvoiceController.php`
- `src/Modules/Accounting/InvoiceIssueException.php`
- `src/Core/Http/MfWhitelistApiClient.php`
- `src/Modules/Orders/OrdersController.php`
- `src/Modules/Orders/OrdersRepository.php`
- `src/Modules/Settings/AllegroOrderImportService.php`
- `src/Modules/Settings/ShopproOrdersSyncService.php`
- `resources/views/orders/show.php`
- `resources/views/accounting/invoice_form.php`
- `resources/views/accounting/invoice_preview.php`
- `resources/views/accounting/invoice_pdf.php`
- `resources/views/accounting/invoices_issued_list.php`
- `public/assets/js/modules/invoice-requested-toggle.js`
- `.paul/phases/115-invoice-from-order/115-01-PLAN.md`
- `.paul/phases/115-invoice-from-order/115-01-SUMMARY.md`
- `.paul/codebase/todo.md`
- `database/migrations/20260511_000107_seed_default_invoice_config.sql`
- `src/Modules/Settings/InvoiceConfigRepository.php`
- `src/Modules/Settings/InvoiceConfigController.php`

View File

@@ -203,6 +203,65 @@ tests/
### IntegrationsHubController
- Nowy parametr konstruktora `FakturowniaIntegrationRepository $fakturownia` i nowa metoda `buildFakturowniaRow()` agregująca status wszystkich kont (count instancji, configured/active counts, ostatni test).
## Phase 115 — Wystawianie faktury z zamowienia
### InvoiceService (`src/Modules/Accounting/InvoiceService.php`)
- `issue(array $params): array` — orchestrator. Walidacja config (active), order details fetch, build snapshots (seller z `company_settings`, buyer merged z payload_json+addresses+manual override, items z VAT-aware netto/brutto split), routing do `issueLocal()` lub `issueDelegated()` zaleznie od `invoice_configs.is_delegated`.
- `issueLocal()``InvoiceRepository::nextLocalNumber()` (atomowy counter z `invoice_number_counters`) -> `insertLocal()` -> zwraca `{invoice_id, invoice_number, total_gross, mode='local'}`.
- `issueDelegated()``FakturowniaApiClient::createInvoice()` PRZED INSERT lokalnym; on success zapis `external_invoice_id`/`external_pdf_url`/`invoice_number` z odpowiedzi API; on failure rzuca `InvoiceIssueException` (zaden wiersz w `invoices`). `invoice_number_counters` NIE jest dotykany dla delegated.
- Static `extractBuyerTaxNumber($order, $buyerAddress)` — parsuje NIP z payload_json sciezki: `invoice.address.taxId` (Allegro), `invoice.taxId/nip`, `buyer.tax_number/nip`, `client.nip/tax_number`, top-level `nip/tax_number`. Fallback na `order_addresses.company_tax_number`.
### InvoiceRepository (`src/Modules/Accounting/InvoiceRepository.php`)
- `findByOrderId/findById` — JOIN `invoices` + `invoice_configs` + `integrations` (type='fakturownia') + `fakturownia_integration_settings` (LEFT JOIN dla `account_prefix`).
- `insertLocal/insertDelegated` — wspolny prywatny `insert()` z roznymi NULL-amizacjami `external_*` pol.
- `nextLocalNumber()``INSERT ... ON DUPLICATE KEY UPDATE last_number = last_number + 1` na `invoice_number_counters`, mirror `ReceiptRepository::getNextNumber`.
- `paginate()` — filtry: `search` (numer/order ref), `config_id`, `mode` (local/delegated rozroznia po `external_invoice_id IS NULL`), `date_from`/`date_to`.
### FakturowniaApiClient (rozszerzony)
- `createInvoice(array $settings, array $invoice)` — POST `https://{prefix}.fakturownia.pl/invoices.json` z body `{api_token, invoice}`. cURL z `SslCertificateResolver`, timeout `$timeoutSeconds`. On 2xx parsuje JSON na `{id, number, view_url, pdf_url, raw}`. On non-2xx rzuca `RuntimeException("HTTP {code}: {error}")`.
- `buildPdfUrl(prefix, invoiceId, apiToken)` — string-builder dla `https://{prefix}.fakturownia.pl/invoices/{id}.pdf?api_token=...`. Bez fetcha; uzywany w redirect 302.
- Dodany `httpPostJson()` (private) odpowiednik istniejacego `httpGet()`.
### InvoiceController (`src/Modules/Accounting/InvoiceController.php`)
- `create(Request)` — GET `/orders/{id}/invoice/create`. Walidacja `orders.invoice_requested=1` (przekierowanie z flash error gdy 0). Active configs (filter `is_active=1`). NIP auto-prefill via `InvoiceService::extractBuyerTaxNumber()`. Renderuje `accounting/invoice_form`.
- `store(Request)` — POST `/orders/{id}/invoice/store`. CSRF, `config_id` walidacja. Wywoluje `InvoiceService::issue()` z buyer overrides z formularza. On success: `OrdersRepository::recordActivity('invoice_issued')`, flash success, redirect na `/orders/{id}/invoice/{invoiceId}`. On `InvoiceIssueException`: flash do `invoice.error`, redirect z powrotem na form.
- `show(Request)` — GET `/orders/{id}/invoice/{invoiceId}`. HTML preview z snapshotow.
- `pdf(Request)` — GET `/orders/{id}/invoice/{invoiceId}/pdf`. Gdy `external_pdf_url` istnieje -> redirect 302; inaczej Dompdf inline z templatu `accounting/invoice_pdf`.
- `issuedList(Request)` — GET `/settings/accounting/invoices/issued`. Filtry GET, paginacja 50/strona.
### orders.invoice_requested toggle
- `OrdersRepository::setInvoiceRequested(int, bool)` — UPDATE z `updated_at = NOW()`.
- `OrdersController::toggleInvoiceRequested` — POST `/orders/{id}/invoice-requested/toggle`. CSRF, JSON response `{success, invoice_requested}`. Loguje `order_activity_log` z `event_type='invoice_requested_changed'`.
- `public/assets/js/modules/invoice-requested-toggle.js` — vanilla JS, idempotent guard `dataset.bound='1'`. AJAX POST przy `change`, optimistic show/hide `[data-invoice-button-wrap]`. Rollback checkbox przy HTTP/network blad.
### Auto-import flagi invoice_requested
- `AllegroOrderImportService::importSingleOrder` — przy `wasCreated=true` jezeli `payload.invoice.required` truthy -> `setInvoiceRequested(true)`. Tylko pierwszy import (delta-only re-import nie nadpisuje manualnej zmiany).
- `ShopproOrdersSyncService::shouldRequestInvoice($rawOrder)` — flexible parser sprawdzajacy `wants_invoice`, `invoice_required`, `invoice.required`, `buyer.wants_invoice`, `buyer.invoice` (akceptuje true/1/'1'/'true'/'yes'/'tak'). Wywolany tylko przy `wasCreated=true`.
### View hierarchy
- `accounting/invoice_form.php` — formularz wystawiania.
- `accounting/invoice_preview.php` — HTML preview po wystawieniu.
- `accounting/invoice_pdf.php` — template Dompdf, mirror `receipts/print.php` z dodatkowymi polami faktury VAT (parties, netto/VAT/brutto per stawka, termin platnosci).
- `accounting/invoices_issued_list.php` — lista pod `/settings/accounting/invoices/issued`.
- `orders/show.php` — checkbox toggle + warunkowy przycisk "Wystaw fakture" + sekcja "Faktury" w tabie documents.
### DI wiring (`routes/web.php`)
- `$invoiceRepository = new InvoiceRepository($app->db());` (po `InvoiceConfigRepository`).
- `$invoiceService = new InvoiceService($invoiceRepository, $invoiceConfigRepository, $companySettingsRepository, new OrdersRepository(...), $fakturowniaIntegrationRepository, $fakturowniaApiClient);`
- `$invoiceController = new InvoiceController($template, $translator, $auth, $invoiceRepository, $invoiceConfigRepository, $companySettingsRepository, new OrdersRepository(...), $invoiceService);`
- `$ordersController` rozszerzony o 2 trailing params: `$invoiceRepository`, `$invoiceConfigRepository`.
### BREAKING / migration
- Zadnych nowych migracji — Phase 113-01 dostarczyla `orders.invoice_requested`, `invoice_configs/invoices/invoice_number_counters` i `fakturownia_integration_settings`.
- `OrdersController` ctor dostal 2 NEW optional params (default null) — backwards compatible.
### Edge cases / known limits
- INVOICE-IDEMP-115 (`.paul/codebase/todo.md`) — brak idempotencji przy double-POST do Fakturowni gdy odpowiedz nie dotrze; operator musi recznie zweryfikowac w panelu.
- Brak `invoice.created` event automatyzacji (per Phase 113 decision).
- Brak download+cache PDF z Fakturowni — tylko redirect 302 (kazdy klik na PDF dla delegated faktury fetchuje PDF z Fakturowni).
---
## Phase 114 — Accounting Configs Refactor
### Sekcja Ksiegowosc — struktura URL

View File

@@ -1,5 +1,53 @@
# Technical Changelog
## 2026-05-10 - Phase 115 Plan 01: Wystawianie faktury z zamowienia
**Co zrobiono:**
- **GUS lookup (Task 3b):** `FakturowniaApiClient::lookupClientByNip()` — GET `/clients/gus.json?nip=...&api_token=...` z walidacja NIP (10 cyfr) i parsowaniem `{name, tax_no, street, post_code, city, country}`. `InvoiceController::gusLookup()` jako AJAX endpoint `GET /api/fakturownia/gus-lookup?nip=...&config_id=...` — resolwer konta Fakturownia (preferuje `invoice_configs.integration_id` gdy delegated, fallback na pierwsze aktywne konto type='fakturownia'). `invoice_form.php` ma przycisk "Pobierz z GUS" obok pola NIP — vanilla JS fetch + auto-fill `buyer_company_name/street/postal_code/city`. Czyni "wystaw fakture" jednoklikowym po wpisaniu NIP. `InvoiceController` ctor rozszerzony o 2 deps (`FakturowniaIntegrationRepository`, `FakturowniaApiClient`).
- `InvoiceRepository` (nowy) — CRUD dla `invoices`: `findByOrderId`, `findById`, `insertLocal`, `insertDelegated`, `nextLocalNumber` (atomowy INSERT ON DUPLICATE KEY UPDATE na `invoice_number_counters`), `paginate` z filtrami search/config/mode/date.
- `InvoiceService` (nowy) — orchestrator wystawiania:
- `issue()` rozgalezia na `issueLocal()` (numer lokalny + Dompdf-ready snapshot) lub `issueDelegated()` (POST do Fakturowni PRZED INSERT lokalnym; na sukcesie zapis `external_invoice_id`/`external_pdf_url`/`invoice_number` z odpowiedzi).
- Static `extractBuyerTaxNumber()` parsuje NIP z roznych sciezek payload_json (Allegro `invoice.address.taxId`, shopPRO `buyer.tax_number` i pokrewne).
- Snapshot pattern (snapshoty seller/buyer/items z Phase 8 wzorca) + obliczanie netto/brutto z VAT per pozycja.
- `buildFakturowniaPayload()` mapuje na format `https://app.fakturownia.pl/api`.
- `InvoiceIssueException` (nowy) — typed exception dla bledow biznesowych.
- `FakturowniaApiClient` rozszerzony — nowe `createInvoice()` (POST `/invoices.json` z body `{api_token, invoice}`, parsuje response na `id/number/view_url/pdf_url/raw`) i `buildPdfUrl()` (helper bez fetcha, do redirect 302). Stary stub `downloadPdf()` zastapiony.
- `InvoiceController` (nowy) — endpointy dla zamowienia (`create`, `store`, `show`, `pdf`) i lista `issuedList`. PDF: lokalna -> Dompdf inline (mirror `ReceiptController::pdf`); delegowana -> redirect 302 na `external_pdf_url`. Walidacja `invoice_requested=1` przed otwarciem formularza.
- `OrdersController::toggleInvoiceRequested` (nowy) — AJAX endpoint POST `/orders/{id}/invoice-requested/toggle` z CSRF + `recordActivity('invoice_requested_changed')`.
- `OrdersRepository::setInvoiceRequested(int, bool)` (nowy) — UPDATE `orders.invoice_requested`.
- `OrdersController::show()` rozszerzone — przekazuje `invoices` + `invoiceConfigs` do widoku przez 2 nowe optional ctor params (`InvoiceRepository`, `InvoiceConfigRepository`).
- `AllegroOrderImportService::importSingleOrder` — przy `wasCreated=true` i `payload.invoice.required` -> ustawia `orders.invoice_requested=1` (delta-only re-import nie nadpisuje manualnej flagi).
- `ShopproOrdersSyncService::shouldRequestInvoice()` (nowy) — flexible parser sprawdzajacy `wants_invoice`, `invoice_required`, `invoice.required`, `buyer.wants_invoice`, `buyer.invoice` w raw payload. Dziala tylko przy pierwszym imporcie.
- 4 nowe widoki:
- `resources/views/accounting/invoice_form.php` — formularz wystawiania (config select z label "Lokalnie/Fakturownia: {prefix}", NIP prefilled z payload, manualne nadpisanie buyer fields, podglad pozycji + sprzedawca; confirm dialog dla powtornego wystawienia uzywa `OrderProAlerts.confirm` z options-object API).
- `resources/views/accounting/invoice_preview.php` — HTML preview faktury z badgem trybu (Lokalnie/Fakturownia: {prefix}) + dual-button PDF.
- `resources/views/accounting/invoice_pdf.php` — Dompdf template "FAKTURA VAT" z parties section, items table (netto/brutto/VAT per pozycja), termin platnosci, konto bankowe.
- `resources/views/accounting/invoices_issued_list.php` — lista wystawionych faktur z filtrami (search/config/mode/date_from/date_to) + paginacja + badge Tryb (Lokalnie / Fakturownia: {prefix}).
- `resources/views/orders/show.php` — header dostal `data-invoice-button-wrap` z przyciskiem "Wystaw fakture" (visible tylko gdy `invoice_requested=1`); pod headerem checkbox `data-invoice-requested-toggle` (auto-bound przez `invoice-requested-toggle.js`); pod tabem "Documents" nowa sekcja "Faktury" z badgem Tryb i akcjami Podglad/PDF.
- `resources/views/settings/accounting.php` — link "Faktury wystawione" w karcie Faktury.
- `public/assets/js/modules/invoice-requested-toggle.js` (nowy) — vanilla JS, AJAX POST z `_token` przy `change`, rollback checkbox state przy bledzie, optimistic show/hide `data-invoice-button-wrap`. Idempotent guard `data-bound`.
- `resources/views/layouts/app.php` — rejestracja `invoice-requested-toggle.js` z cache-busting.
- 6 nowych routes: `POST /orders/{id}/invoice-requested/toggle`, `GET /orders/{id}/invoice/create`, `POST /orders/{id}/invoice/store`, `GET /orders/{id}/invoice/{invoiceId}`, `GET /orders/{id}/invoice/{invoiceId}/pdf`, `GET /settings/accounting/invoices/issued`. Wszystkie pod `AuthMiddleware`.
- DI wiring w `routes/web.php`: `InvoiceRepository`, `InvoiceService`, `InvoiceController`. `OrdersController` instantiation rozszerzona o 2 params (`$invoiceRepository`, `$invoiceConfigRepository`).
- Docs: `architecture.md` (nowa sekcja "Phase 115"), `tech_changelog.md`, `todo.md` (INVOICE-IDEMP-115).
**Dlaczego:**
- Plan zatwierdzony jako pelny vertical slice — Phase 113 (DB) + 114 (configs CRUD) byly fundamentem; bez wystawiania z zamowienia funkcja byla bezuzyteczna operacjonalnie.
- Kolejnosc "POST do Fakturowni najpierw, INSERT lokalny po sukcesie" (per user decision) gwarantuje ze nie ma orphan rows w `invoices` gdy API padnie. Trade-off: brak idempotencji przy double-POST -> notatka INVOICE-IDEMP-115.
- Auto-set `invoice_requested` z importu pozwala operatorowi nie myslec o flagi dla typowych przypadkow (Allegro `invoice.required=true`, shopPRO klienci wpisujacy NIP). Manualny toggle dla edge cases / korekty.
- Dual PDF flow (Dompdf lokalny / redirect 302 do Fakturowni) — operator dostaje "natywny" PDF z Fakturowni dla delegowanych (ten sam co ksiegowy widzi w Fakturowni), brak ryzyka rozjazdu wygladu/numeracji.
- `is_delegated=0` config zachowany jako pelny tryb lokalny — dla scenariuszy gdzie operator nie chce/nie ma konta Fakturowni.
**BREAKING:** Brak. `OrdersController` ctor dostal 2 NEW optional params (default null) — backwards compatible.
**Szczegolne uwagi:**
- `invoice-config-form.js` (Phase 114) i `invoice-requested-toggle.js` (Phase 115) — dwa rozne moduly, oba w `layouts/app.php`. Nazwa "invoice-*" ale rozne kontekstowo (jeden formularz config, drugi zamowienie).
- `OrderProAlerts.confirm` w `invoice_form.php` uzywa options-object API zgodnie z memory (Phase 114 fix).
- Migracje no-op nie potrzebne — Phase 113 dostarczyla `orders.invoice_requested`, `invoice_*` tabele i `fakturownia_integration_settings`.
- Skill audit: brak required skills dla tego planu.
---
## 2026-05-10 - Phase 114 Plan 01: Accounting Configs Refactor
**Co zrobiono:**

View File

@@ -2,6 +2,23 @@
> Lista nieformalnych zadan do zrobienia pozniej. Kazdy wpis ma wlasny tag (np. `STAT-NET`) zeby mozna go bylo zlinkowac z komentarzy w kodzie.
## INVOICE-IDEMP-115 — idempotencja podwojnego POST do Fakturowni (data: 2026-05-10)
### Kontekst
- Phase 115-01 — wystawianie faktury z zamowienia (delegacja Fakturownia).
- Flow: `InvoiceService::issueDelegated()` -> POST do Fakturowni -> on success INSERT do `invoices`.
- Edge case: faktura zostala utworzona w Fakturowni, ale odpowiedz nie dotarla (timeout, network). Operator widzi blad, klika "Wystaw fakture" ponownie -> drugi POST -> Fakturownia tworzy DRUGA fakture.
### Zadania
1. Dorzucic idempotency-key (np. UUID per attempt zachowany w sesji albo w `invoices` ze statusem `pending_external`).
2. Sprawdzic czy Fakturownia API wspiera nag/lowek `Idempotency-Key` lub deduplikacje po referencji.
3. Alternatywa: po bledzie API, przed kolejnym POST, query Fakturowni `GET /invoices.json?q=<order_reference>` zeby sprawdzic czy faktura juz istnieje.
### Status
- Odlozone — operator musi recznie zweryfikowac w panelu Fakturowni przy bledach API.
---
## STAT-NET — netto zamowien w statystykach (data: 2026-04-19)
### Kontekst

View File

@@ -0,0 +1,359 @@
---
phase: 115-invoice-from-order
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Accounting/InvoiceRepository.php
- src/Modules/Accounting/InvoiceService.php
- src/Modules/Accounting/InvoiceIssueException.php
- src/Modules/Accounting/InvoiceController.php
- src/Modules/Accounting/InvoicePdfRenderer.php
- src/Modules/Settings/FakturowniaApiClient.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Allegro/AllegroOrderImportService.php
- src/Modules/Settings/ShopPro/ShopproOrdersSyncService.php
- resources/views/orders/show.php
- resources/views/accounting/invoices_list.php
- resources/views/accounting/invoice_form.php
- resources/views/accounting/invoice_preview.php
- public/assets/js/modules/invoice-requested-toggle.js
- routes/web.php
- bootstrap/app.php
autonomous: false
delegation: off
---
<objective>
## Goal
Wystawianie faktur dla klientow z NIP bezposrednio z zamowienia, w dwoch trybach (lokalny Dompdf z `invoice_number_counters` lub delegowany do Fakturowni z importem rezultatu), z auto-prefillem NIP, manualnym togglem flagi `orders.invoice_requested`, lista faktur w sekcji Ksiegowosc i podgladem/PDF analogicznym do paragonow.
## Purpose
Domknieciem milestone v3.7 jest zamiana fundamentu z faz 113-114 na uzyteczna funkcjonalnosc operatorska: jedna sciezka biznesowa "zamowienie z NIP -> wystawiona faktura w PDF" z opcjonalnym outsourcingiem numeracji do Fakturowni.
## Output
Operator widzi przycisk "Wystaw fakture" w szczegolach zamowienia tylko gdy `invoice_requested=1`, otwiera formularz (NIP auto z payload_json + manualny override), wybiera config (lokalny lub delegowany), generuje fakture; w trybie delegowanym faktura tworzy sie najpierw w Fakturowni a potem importuje do `invoices`. Lista `/settings/accounting/invoices/issued` pokazuje wystawione dokumenty z filtrami i podgladem PDF.
</objective>
<context>
<clarifications>
- **Trigger UI** — Jak ma wygladac trigger wystawiania faktury w szczegolach zamowienia?
→ Odpowiedz: Przycisk 'Wystaw fakture' widoczny tylko gdy `orders.invoice_requested=1`.
- **NIP nabywcy** — Skad ma byc pobierany NIP nabywcy?
→ Odpowiedz: Auto z `orders.payload_json` (Allegro `invoice.address.taxId` / shopPRO equivalent) z manualnym polem fallback w formularzu.
- **Delegacja** — Jak ma dzialac flow gdy `invoice_configs.is_delegated=1`?
→ Odpowiedz: Wszystko ma byc robione w Fakturowni (POST najpierw), a po sukcesie importowane do orderPRO (INSERT do `invoices` z `external_invoice_id`, numerem zwroconym przez Fakturownie i `external_pdf_url`).
- **invoice_requested** — Jak operator ma ustawiac flage `orders.invoice_requested=1`?
→ Odpowiedz: Toggle/checkbox w szczegolach zamowienia (manualny, AJAX) + auto z importu gdy zrodlo zglasza zadanie faktury (Allegro `invoice.required` / shopPRO `wants_invoice`-equivalent w payload).
- **Lokalny tryb** — Co dzieje sie dla configs gdzie `is_delegated=0`?
→ Odpowiedz: Lokalna numeracja przez `invoice_number_counters` + Dompdf PDF, analogicznie do paragonow (reuse pattern z `ReceiptService`).
- **Zakres** — Czy plan obejmuje liste faktur i podglad/PDF?
→ Odpowiedz: Tak - dolacz wystawianie + lista faktur + podglad/PDF (vertical slice).
</clarifications>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Prior Work
@.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md
@.paul/phases/114-accounting-configs-refactor/114-01-SUMMARY.md
## Source Files (reuse patterns)
@src/Modules/Accounting/ReceiptService.php
@src/Modules/Accounting/ReceiptRepository.php
@src/Modules/Accounting/ReceiptController.php
@src/Modules/Settings/FakturowniaApiClient.php
@src/Modules/Settings/FakturowniaIntegrationRepository.php
@src/Modules/Settings/InvoiceConfigRepository.php
@resources/views/accounting/receipts_list.php
</context>
<acceptance_criteria>
## AC-1: Flaga invoice_requested ustawia widocznosc przycisku
```gherkin
Given zamowienie #X ma `orders.invoice_requested=0`
When operator otwiera /orders/X
Then przycisk "Wystaw fakture" NIE jest widoczny w headerze, a checkbox/toggle "Klient prosi o fakture" jest widoczny i mozna go wlaczyc
Given operator klika toggle "Klient prosi o fakture"
When AJAX POST /orders/X/invoice-requested/toggle zwraca 200
Then `orders.invoice_requested` zmienia sie na 1, przycisk "Wystaw fakture" pojawia sie bez przeladowania strony (lub po reloadzie), a `order_activity_log` zapisuje wpis `invoice_requested_changed`
Given Allegro order import ma payload `order.invoice.required=true` (lub shopPRO `wants_invoice=1`)
When OrderImportService zapisuje zamowienie pierwszy raz
Then `orders.invoice_requested=1` ustawione automatycznie (delta-only re-import nie nadpisuje rozni-od-DB - flaga manualna pozostaje stabilna)
```
## AC-2: Wystawienie faktury - tryb lokalny
```gherkin
Given config #C ma `is_delegated=0`, zamowienie #X ma `invoice_requested=1` i NIP w payload_json
When operator otwiera /orders/X/invoice/create i wybiera config #C
Then formularz pre-fillem pokazuje NIP nabywcy z payload_json, dane firmy z company_settings, items z order_items (z snapshotu jak w receipts), i operator moze nadpisac NIP
When operator submituje form
Then InvoiceService:
- generuje numer lokalny przez `InvoiceNumberCounter` (atomowy INSERT ON DUPLICATE KEY) z formatu `invoice_configs.number_format`
- INSERT do `invoices` z snapshotami JSON (`seller_data_json`, `buyer_data_json` z NIP, `items_json`)
- generuje PDF przez Dompdf (reuse Dompdf wiring z `ReceiptService`) i nie zapisuje na dysku (PDF on-demand z snapshotu)
- zwraca redirect na /orders/X/invoice/{invoiceId} z flash success
```
## AC-3: Wystawienie faktury - tryb delegowany (Fakturownia)
```gherkin
Given config #C ma `is_delegated=1` i wskazuje na konto Fakturownia (integration_id), zamowienie #X ma NIP
When operator submituje formularz invoice/create dla #C
Then InvoiceService:
- WOLA `FakturowniaApiClient::createInvoice(prefix, token, payload)` PRZED jakimkolwiek INSERT lokalnym
- po sukcesie API (HTTP 2xx z `id` i `number` w response) INSERT do `invoices` z `external_invoice_id`, `external_pdf_url`, `invoice_number` zwroconym przez Fakturownie i lokalnymi snapshotami (`seller_data_json`/`buyer_data_json`/`items_json` zamrozone w orderPRO dla audytu)
- przy bledzie API (HTTP non-2xx, timeout, JSON parse fail) NIE robi INSERT, zwraca redirect na form z bledem `Flash::set('invoice.error', ...)` i czytelnym opisem (status code + message z Fakturowni)
- `invoice_number_counters` NIE jest dotykany (numer pochodzi z Fakturowni)
Given faktura zostala juz utworzona w Fakturowni ale POST sie wywalil sieciowo po stronie Fakturowni
When operator probuje ponownie submit form (idempotencja na poziomie aplikacji)
Then aplikacja nie zabezpiecza tego automatycznie w tym planie - operator musi recznie pojsc do Fakturowni i sprawdzic; ten przypadek odnotowany w `.paul/codebase/todo.md` jako INVOICE-IDEMP-115
```
## AC-4: Lista, podglad i PDF
```gherkin
Given istnieja faktury wystawione (lokalne i delegowane)
When operator otwiera /settings/accounting/invoices/issued
Then widzi tabele analogiczna do `/settings/accounting/receipts/issued`:
- kolumny: numer, data wystawienia, sprzedawca, nabywca (z NIP), kwota brutto, tryb (Lokalny/Fakturownia), zamowienie (link)
- filtry: zakres dat (issue_date), config, tryb (lokalny/delegowany)
- sortowanie po dacie DESC
Given lokalna faktura #I
When operator klika "Podglad"
Then otwiera sie /accounting/invoices/{I}/preview - HTML render z snapshotow (analogicznie do `receipts/{id}`)
Given lokalna faktura #I
When operator klika "PDF"
Then GET /accounting/invoices/{I}/pdf zwraca `Content-Type: application/pdf` wygenerowany przez Dompdf z snapshotow
Given delegowana faktura #I (ma `external_pdf_url`)
When operator klika "PDF"
Then redirect 302 na `external_pdf_url` (PDF z Fakturowni, nie cache'ujemy lokalnie w tym planie)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Backend - InvoiceService + InvoiceRepository + Fakturownia API client</name>
<files>
src/Modules/Accounting/InvoiceRepository.php (nowy),
src/Modules/Accounting/InvoiceService.php (nowy),
src/Modules/Accounting/InvoiceIssueException.php (nowy),
src/Modules/Accounting/InvoicePdfRenderer.php (nowy),
src/Modules/Settings/FakturowniaApiClient.php,
bootstrap/app.php
</files>
<action>
1. **InvoiceRepository** (mirror `ReceiptRepository`):
- `insertLocal(array $data): int` - INSERT do `invoices` (numer lokalny, brak external_*).
- `insertDelegated(array $data): int` - INSERT z `external_invoice_id`, `external_pdf_url`, numerem z Fakturowni.
- `findById(int): ?array`, `listAll(array $filters): array` (filtry: dateFrom/To, configId, mode), `countAll(array $filters): int` dla paginacji.
- `nextLocalNumber(int $configId, DateTimeImmutable $issueDate, string $format, string $type): string` - atomowy INSERT ON DUPLICATE KEY UPDATE na `invoice_number_counters` (reuse wzorca z `ReceiptRepository::nextNumber`); placeholder `%N` zerro-padded width zachowany.
2. **InvoiceService**:
- `issue(int $orderId, int $configId, array $formInput): int` - orchestrator:
- laduje order, items, addresses, company_settings;
- buduje `seller_data_json`, `buyer_data_json` (z NIP merged: payload_json prefill + manual override z `formInput['buyer_tax_number']`), `items_json` (reuse `buildItemsSnapshot` pattern);
- jezeli `is_delegated=0`: alokuje numer lokalny -> `insertLocal` -> zwraca id;
- jezeli `is_delegated=1`: pobiera `account_prefix`, `apiToken` przez `FakturowniaIntegrationRepository::getDecryptedToken` -> `FakturowniaApiClient::createInvoice` -> on success `insertDelegated` z `external_invoice_id`, `external_pdf_url`, `invoice_number` z odpowiedzi -> zwraca id; on failure rzuca `InvoiceIssueException` z czytelnym message.
- Zera tolerancji: brak `is_delegated=1 && integration_id=NULL` (rzuca exception).
3. **FakturowniaApiClient::createInvoice(string $prefix, string $apiToken, array $payload): array**:
- POST `https://{prefix}.fakturownia.pl/invoices.json` z body `{api_token, invoice: {...}}` (zgodnie z https://app.fakturownia.pl/api - mapping: `kind`, `seller_*` (z snapshot or omitted gdy konto Fakturowni ma juz dane), `buyer_*`, `positions: [...]`, `payment_to_kind`, `department_id`).
- cURL z timeoutem (timeout_seconds z `integrations.timeout_seconds`), `SslCertificateResolver::resolve()`.
- Zwraca `['id' => int, 'number' => string, 'view_url' => string, 'raw' => array]` lub rzuca `RuntimeException` przy non-2xx z message: `"Fakturownia API HTTP {code}: {error}"`.
4. **FakturowniaApiClient::downloadPdfUrl(string $prefix, int $invoiceId): string**:
- Konstruuje URL `https://{prefix}.fakturownia.pl/invoices/{id}.pdf?api_token={token}` - jako string-builder, bez fetcha (uzywany w redirect 302).
5. **InvoicePdfRenderer** - reuse Dompdf wiring z `ReceiptService` (jezeli jest extracted) lub mirror; renderuje template `invoice_pdf.php` z snapshotow.
6. **Bootstrap wiring** w `bootstrap/app.php`: rejestracja `InvoiceRepository`, `InvoiceService` (deps: InvoiceRepository, InvoiceConfigRepository, FakturowniaIntegrationRepository, FakturowniaApiClient, OrdersRepository, CompanySettingsRepository).
</action>
<verify>
`php -l src/Modules/Accounting/InvoiceService.php` (0 errors).
Manual: `php -r "require 'bootstrap/app.php'; var_dump(\$app->make(InvoiceService::class));"` (gdy XAMPP online; opcjonalne).
</verify>
<done>AC-2 (lokalny insert), AC-3 (delegated POST najpierw, INSERT po sukcesie) - infrastruktura backend gotowa.</done>
</task>
<task type="auto">
<name>Task 2: UI zamowienia - toggle invoice_requested + form wystawiania + endpointy + auto-import flagi</name>
<files>
src/Modules/Orders/OrdersController.php,
src/Modules/Orders/OrdersRepository.php,
src/Modules/Accounting/InvoiceController.php (nowy),
src/Modules/Allegro/AllegroOrderImportService.php,
src/Modules/Settings/ShopPro/ShopproOrdersSyncService.php,
resources/views/orders/show.php,
resources/views/accounting/invoice_form.php (nowy),
public/assets/js/modules/invoice-requested-toggle.js (nowy),
routes/web.php
</files>
<action>
1. **OrdersController + Repository**:
- `OrdersRepository::setInvoiceRequested(int $orderId, bool $value): void` - UPDATE + insert do `order_activity_log` (`event_type='invoice_requested_changed'`, summary "Klient prosi o fakture: tak/nie").
- `OrdersController::toggleInvoiceRequested(Request)` - POST `/orders/{id}/invoice-requested/toggle` z `_token`; przyjmuje `invoice_requested=0|1`; zwraca JSON `{success: true, invoice_requested: int}`.
2. **InvoiceController** (`src/Modules/Accounting/InvoiceController.php`):
- `create(Request)` - GET `/orders/{id}/invoice/create` - lista activnych `invoice_configs` (z label trybu), prefill NIP z `orders.payload_json` (helper `extractBuyerTaxNumber($order)` parsuje Allegro: `payload_json.invoice.address.taxId`, shopPRO: `payload_json.buyer.tax_number` lub fallback `''`); render `invoice_form.php`.
- `store(Request)` - POST `/orders/{id}/invoice/store` - walidacja (`config_id required`, `buyer_tax_number required gdy is_delegated=1 lub gdy config wymaga NIP`); wywoluje `InvoiceService::issue()`; flash success/error; redirect na `/orders/{id}/invoice/{invoiceId}` (preview) lub z powrotem na form przy bledzie.
- `show(Request)` - GET `/orders/{id}/invoice/{invoiceId}` - HTML preview (mirror `ReceiptController::show`).
- `pdf(Request)` - GET `/orders/{id}/invoice/{invoiceId}/pdf` - jezeli `external_pdf_url` istnieje, redirect 302; inaczej Dompdf stream przez `InvoicePdfRenderer`.
3. **resources/views/orders/show.php**:
- Header: po `Wystaw paragon` dodaj `<?php if ($order['invoice_requested'] ?? 0): ?>` -> `<a href="/orders/.../invoice/create" class="btn btn--secondary">Wystaw fakture</a>`.
- Sekcja akcji ponizej: checkbox `[data-invoice-requested-toggle]` z label "Klient prosi o fakture" - vanilla JS modul `invoice-requested-toggle.js` POSTuje `_token` przy `change`, ukrywa/pokazuje `[data-invoice-button-wrap]` bez reloadu.
- Po zakladce paragonow dodaj liste faktur dla zamowienia (analogicznie do paragonow) z kolumna Tryb i `external_pdf_url`-aware przyciskiem PDF.
4. **invoice_form.php** (analogicznie do `receipt_form.php`):
- Select configa (label: `name (Lokalny)` lub `name (Fakturownia: {integration_name})`).
- Pola: `buyer_tax_number` (prefill, edytowalne), `buyer_name`, `buyer_address`, items (readonly podglad z order_items), data wystawienia (default today), data sprzedazy (z `sale_date_source` z config).
- CSRF `_token`, button submit.
5. **AllegroOrderImportService** + **ShopproOrdersSyncService**:
- W `upsertOrderAggregate` dodaj `'invoice_requested' => $this->extractInvoiceRequestedFromPayload($payload)` - tylko przy `created=true` (delta-only re-import nie nadpisuje manualnej flagi).
- Allegro: `payload.invoice.required === true` -> 1, inaczej 0.
- shopPRO: parser zalezny od formatu payload (`buyer.wants_invoice`, `invoice.requested`, lub fallback 0 - dokladny klucz weryfikowany na payloadzie podczas implementacji).
6. **routes/web.php**: nowe routy `/orders/{id}/invoice-requested/toggle` (POST), `/orders/{id}/invoice/create` (GET), `/orders/{id}/invoice/store` (POST), `/orders/{id}/invoice/{invoiceId}` (GET), `/orders/{id}/invoice/{invoiceId}/pdf` (GET). Wszystkie pod `AuthMiddleware`. POST z CSRF `_token`.
</action>
<verify>
`php -l` na zmienionych plikach.
Smoke (XAMPP online): toggle invoice_requested -> reload -> przycisk widoczny -> klik -> form -> submit lokalny -> preview otwiera sie.
</verify>
<done>AC-1 (toggle + auto-import), AC-2 (lokalny flow do preview), AC-3 (delegated form path).</done>
</task>
<task type="auto">
<name>Task 3: Lista wystawionych faktur w sekcji Ksiegowosc + podglad/PDF</name>
<files>
src/Modules/Accounting/InvoiceController.php,
resources/views/accounting/invoices_issued_list.php (nowy),
resources/views/accounting/invoice_preview.php (nowy),
resources/views/accounting/invoice_pdf.php (nowy),
resources/views/accounting/_hub.php (lub komponent "Faktury wystawione" link),
routes/web.php
</files>
<action>
1. **InvoiceController::issuedList(Request)**:
- GET `/settings/accounting/invoices/issued`
- Filtry: `date_from`, `date_to`, `config_id`, `mode` (`local`/`delegated`/`all`), `page` (paginacja, 50 per page).
- Wywoluje `InvoiceRepository::listAll($filters)` + `countAll`.
- Renderuje `invoices_issued_list.php` (mirror `receipts_list.php` UI: filter form, table, paginacja).
2. **invoices_issued_list.php**:
- Kolumny: Numer (link to preview), Data wystawienia, Nabywca (z NIP), Kwota brutto, Tryb (badge `Lokalny`/`Fakturownia: {prefix}`), Zamowienie (link `/orders/{id}`), Akcje (Podglad, PDF).
- PDF dla `external_pdf_url` -> `<a href="{external_pdf_url}" target="_blank">PDF (Fakturownia)</a>`; dla lokalnych -> `<a href="/accounting/invoices/{id}/pdf">PDF</a>`.
3. **invoice_preview.php** (HTML preview z snapshotow):
- Header z numerem, data wystawienia, sprzedawca (z `seller_data_json`), nabywca (z `buyer_data_json` w tym NIP), tabela items (z `items_json`), suma netto/VAT/brutto, dane platnosci (`payment_due_date`).
- Przycisk "Pobierz PDF" (lokalny -> Dompdf endpoint, delegowany -> external_pdf_url).
- Mode-aware footer (lokalna faktura: "Wystawione lokalnie w orderPRO"; delegowana: "Wystawione w Fakturowni - id {external_invoice_id}").
4. **invoice_pdf.php** - template renderowany przez Dompdf, mirror `receipt_pdf.php` z dodatkowymi polami faktury VAT (NIP nabywcy, termin platnosci, sposob platnosci, podsumowanie VAT per stawka).
5. **_hub.php / accounting hub view**: dodaj link "Faktury wystawione" obok "Faktury (konfiguracja)" w karcie Faktury (analogicznie do paragonow).
6. **routes/web.php**: `/settings/accounting/invoices/issued` (GET), `/accounting/invoices/{id}/preview` i `/accounting/invoices/{id}/pdf` (GET) pod AuthMiddleware.
</action>
<verify>
`php -l` na zmienionych plikach. Smoke test: po wystawieniu lokalnej + delegowanej faktury - obie widoczne na liscie z poprawnym Trybem; klik "Podglad" otwiera preview; klik "PDF" lokalny -> Dompdf inline; "PDF" delegowany -> redirect do Fakturowni.
</verify>
<done>AC-4 (lista + podglad + PDF dual-path).</done>
</task>
<task type="auto">
<name>Task 3b: GUS lookup — pobieranie danych nabywcy z NIP przez Fakturownie</name>
<files>
src/Modules/Settings/FakturowniaApiClient.php,
src/Modules/Accounting/InvoiceController.php,
resources/views/accounting/invoice_form.php,
routes/web.php
</files>
<action>
1. **FakturowniaApiClient::lookupClientByNip(string $prefix, string $apiToken, string $nip): array**:
- GET `https://{prefix}.fakturownia.pl/clients/gus.json?nip={nip}&api_token={token}`
- Reuse istniejacy `httpGet()` (z `SslCertificateResolver`). Parsowanie JSON na `{name, tax_no, street, post_code, city, country, ...}`.
- Rzuca `RuntimeException` przy non-2xx lub niepoprawnej odpowiedzi.
2. **InvoiceController::gusLookup(Request)**:
- GET `/api/fakturownia/gus-lookup?nip=...&config_id=...` pod AuthMiddleware.
- Walidacja NIP: 10 cyfr (po usunieciu spacji/myslnikow). Niepoprawny -> JSON `{success:false, error:...}` 422.
- Resolwer integration_id: jezeli `config_id` wskazuje config z `is_delegated=1` -> jego `integration_id`. Inaczej fallback na pierwsza aktywna integracje `type=fakturownia` z `is_active=1`. Brak -> JSON `{success:false, error:'Brak skonfigurowanego konta Fakturownia.'}`.
- Pobranie tokenu przez `FakturowniaIntegrationRepository::getDecryptedToken()`. Brak tokenu -> JSON error.
- Wywolanie `FakturowniaApiClient::lookupClientByNip()`. On success zwraca JSON `{success:true, data:{company_name, street, postal_code, city, country, ...}}` znormalizowany na klucze formularza.
3. **invoice_form.php**:
- Przycisk "Pobierz dane z GUS" obok pola `buyer_tax_number`. Klik -> czyta NIP, wywoluje `/api/fakturownia/gus-lookup`, wypelnia `buyer_company_name/street/city/postal_code`. Loading state na buttonie. Bledy przez `OrderProAlerts.error`.
- Inline `<script>` (vanilla JS, bez nowego modulu — specyficzne dla widoku).
4. **routes/web.php**: `GET /api/fakturownia/gus-lookup` -> `[$invoiceController, 'gusLookup']` z AuthMiddleware.
</action>
<verify>
`php -l` na zmienionych plikach. Smoke: NIP istniejacej firmy (np. swojej) + klik "Pobierz z GUS" -> formularz wypelnia sie nazwa/adresem; nieprawidlowy NIP -> czerwony alert.
</verify>
<done>AC-2 rozszerzone o auto-fill nabywcy z NIP — operator nie musi wpisywac danych firmy recznie.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Pelny flow wystawiania faktur z zamowienia: toggle invoice_requested, formularz, dwa tryby (lokalny + delegowany), lista wystawionych faktur, podglad, PDF (Dompdf lokalny + redirect do Fakturowni dla delegowanych), auto-import flagi z Allegro/shopPRO.
</what-built>
<how-to-verify>
1. Pobrac fresh z brancha, `php bin/migrate.php` (Phase 113-114 migracje juz w DB).
2. (Opcjonalnie) Skonfigurowac konto Fakturowni testowej: `/settings/integrations/fakturownia/edit` -> dodac konto, test connection.
3. W `/settings/accounting/invoices` upewnic sie ze istnieja: 1 config lokalny (`is_delegated=0`, np. seed `Domyslny VAT`) i 1 config delegowany (`is_delegated=1` -> integration_id konta Fakturownia).
4. Otworzyc dowolne zamowienie z NIP w payload_json (Allegro lub shopPRO testowe). Sprawdzic checkbox "Klient prosi o fakture".
- Toggle on -> przycisk "Wystaw fakture" pojawia sie. Toggle off -> znika.
5. Klik "Wystaw fakture" -> formularz, NIP prefilled z payload_json. Wybrac config lokalny -> submit -> sprawdzic preview, kliknac PDF -> Dompdf otwiera fakture VAT.
6. Wystawic kolejna na tym samym zamowieniu - tym razem config delegowany -> sprawdzic ze:
- Pojawia sie w `/settings/accounting/invoices/issued` z badgem "Fakturownia",
- Klik PDF -> redirect na external_pdf_url (Fakturownia),
- W panelu Fakturowni faktura widoczna z tym samym numerem.
7. Sprawdzic blad-path: w UI ustawic config delegowany, ale w `/settings/integrations/fakturownia` zepsuc api_token (typo) -> probuje wystawic -> czerwony flash z message zawierajacym HTTP code i `invoices` ZADEN nowy wiersz.
8. Re-import zamowienia (Allegro/shopPRO) - sprawdzic ze manualne `invoice_requested` nie zostaje nadpisane (delta-only).
</how-to-verify>
<resume-signal>Wpisz "approved" by zamknac plan, lub opisz problemy do naprawy</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `src/Modules/Settings/FakturowniaIntegrationRepository.php` - kontrakt z Phase 113 stabilny (testConnection + getDecryptedToken).
- `src/Modules/Settings/InvoiceConfigRepository.php` - kontrakt z Phase 114 stabilny.
- `database/migrations/*` - schema z Phase 113 wystarcza, nie dodajemy migracji w tym planie.
- `src/Modules/Accounting/Receipt*` - paragonowe sciezki niezmienione.
- `OrderImportRepository::upsertOrderAggregate` delta-only logic z Phase 112 - tylko dodanie `invoice_requested` jako kolejnego pola w aggregate (przy `created=true`), bez zmiany kontraktu delta-only.
## SCOPE LIMITS
- Brak `invoice.created` eventu automatyzacji (decyzja z Phase 113 PROJECT.md - odlozone).
- Brak idempotencji aplikacyjnej dla podwojnego POST do Fakturowni (notatka INVOICE-IDEMP-115 w `.paul/codebase/todo.md`).
- Brak download+cache PDF z Fakturowni do storage/ - tylko redirect 302 do `external_pdf_url`.
- Brak edycji wystawionej faktury (immutable po wystawieniu).
- Brak korekt/anulowania faktur w tym planie.
- Brak eksportu XLSX listy faktur (analogiczne do paragonow - moze byc dodane pozniej).
- Auto-import shopPRO `wants_invoice` zalezny od dokladnego klucza w payload - jezeli payload nie ma takiego pola, parser zwraca 0 (manualny toggle nadal dziala).
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l` przechodzi dla wszystkich nowych/zmienionych plikow.
- [ ] AC-1: toggle dziala, log jest tworzony, przycisk pojawia/znika; auto-import flagi dziala dla Allegro przy nowym zamowieniu.
- [ ] AC-2: lokalna faktura tworzy sie, ma numer wg `number_format`, snapshoty zamrozone, PDF Dompdf renderuje sie.
- [ ] AC-3: delegowana faktura - POST PRZED INSERT; przy bledzie API zaden wiersz w `invoices`; przy sukcesie `external_invoice_id`/`external_pdf_url` zapisane.
- [ ] AC-4: lista pokazuje oba tryby, filtry dzialaja, podglad otwiera sie, PDF lokalny i PDF delegowany (redirect) dzialaja.
- [ ] Re-import nie nadpisuje manualnej flagi `invoice_requested`.
- [ ] Smoke test (checkpoint Task 4) zaakceptowany przez operatora.
</verification>
<success_criteria>
- Wszystkie tasks (1-3) zaimplementowane, checkpoint zaakceptowany.
- Brak nowych warningow PHP.
- `.paul/codebase/architecture.md` zaktualizowany (sekcja "Phase 115 - Invoice From Order").
- `.paul/codebase/db_schema.md` bez zmian (Phase 113 wystarcza).
- `.paul/codebase/tech_changelog.md` ma wpis Phase 115.
- INVOICE-IDEMP-115 dodany do `.paul/codebase/todo.md`.
</success_criteria>
<output>
After completion, create `.paul/phases/115-invoice-from-order/115-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,229 @@
---
phase: 115-invoice-from-order
plan: 01
subsystem: accounting
tags: [fakturownia, invoice, dompdf, nip-lookup, mf-whitelist, vat]
requires:
- phase: 113-fakturownia-integration
provides: [tabele invoices/invoice_configs/invoice_number_counters/fakturownia_integration_settings, FakturowniaApiClient stub, IntegrationsRepository::updateTestResult]
- phase: 114-accounting-configs-refactor
provides: [InvoiceConfigRepository CRUD, hub `/settings/accounting`, seed Domyslny VAT config]
provides:
- Wystawianie faktury z zamowienia (lokalne + delegowane przez Fakturownie)
- Toggle orders.invoice_requested w UI + auto-import flagi z Allegro/shopPRO
- Auto-fill nabywcy z NIP przez MF Biala Lista (publiczny endpoint)
- Lista wystawionych faktur z filtrami + podglad/PDF dual-path (Dompdf lokalny / redirect 302 Fakturownia)
affects: [v3.7 next phases, statistics_orders (jezeli kiedys liczy faktury), automation (jezeli kiedys invoice.created event)]
tech-stack:
added: [MfWhitelistApiClient (Core/Http)]
patterns:
- "Dual-mode invoice issuance: lokalna numeracja (atomowy counter) lub delegacja do API z importem rezultatu"
- "Snapshot pattern: seller/buyer/items zamrozone jako JSON niezaleznie od trybu wystawienia"
- "Public NIP lookup przez MF Biala Lista — bez rejestracji/klucza"
key-files:
created:
- src/Modules/Accounting/InvoiceRepository.php
- src/Modules/Accounting/InvoiceService.php
- src/Modules/Accounting/InvoiceController.php
- src/Modules/Accounting/InvoiceIssueException.php
- src/Core/Http/MfWhitelistApiClient.php
- resources/views/accounting/invoice_form.php
- resources/views/accounting/invoice_preview.php
- resources/views/accounting/invoice_pdf.php
- resources/views/accounting/invoices_issued_list.php
- public/assets/js/modules/invoice-requested-toggle.js
modified:
- src/Modules/Settings/FakturowniaApiClient.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Settings/AllegroOrderImportService.php
- src/Modules/Settings/ShopproOrdersSyncService.php
- resources/views/orders/show.php
- resources/views/layouts/app.php
- resources/views/settings/accounting.php
- routes/web.php
key-decisions:
- "GUS lookup: switch z (nieistniejacego) Fakturownia endpoint na MF Biala Lista — public API, bez rejestracji"
- "Fakturownia payload: NIE wysylamy seller_* — security konta interpretuje jako 'utworz nowy dzial' i odrzuca"
- "Delegated invoice: POST do Fakturowni PRZED INSERT lokalnym; on success zapis external_invoice_id+pdf_url+numer Fakturowni"
- "PHP 8.5: usuniete wszystkie curl_close() (deprecated, wycieka HTML do JSON response)"
- "invoice_requested auto-set tylko przy created=true; manualny toggle nie nadpisywany przez delta-only re-import"
- "PDF dual-path: lokalna -> Dompdf inline; delegowana -> redirect 302 do external_pdf_url (brak cache)"
patterns-established:
- "Confirm dialogs: OrderProAlerts.confirm options-object API ({title, message, onConfirm, danger}) — kontynuacja Phase 114"
- "AJAX endpoint zwracajacy JSON: zawsze try/catch Throwable na koncu controller method, mapowanie do {success, error}"
- "Vanilla JS modul z idempotent data-bound guard (jak invoice-requested-toggle.js)"
duration: ~4h
started: 2026-05-10T~14:00:00Z
completed: 2026-05-10T~18:00:00Z
---
# Phase 115 Plan 01: Wystawianie faktury z zamowienia — Summary
**Vertical slice "zamowienie z NIP → faktura PDF" dziala end-to-end w trybach lokalnym (Dompdf + lokalna numeracja z `invoice_number_counters`) i delegowanym (POST do Fakturowni + redirect 302 do natywnego PDF). NIP rozwijany przez MF Biala Liste. Phase 115 zamyka v3.7 milestone w wymiarze "wystawianie".**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~4h (planowanie+implementacja+smoke+5 iteracji bugfix) |
| Tasks | 4 zaplanowane + 1 dodany w trakcie (Task 3b: NIP lookup) |
| Files created | 10 |
| Files modified | 9 |
| New DB migrations | 0 (Phase 113 wystarczyl) |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Flaga invoice_requested ustawia widocznosc przycisku | Pass | Toggle dziala AJAX-em, log w order_activity_log; auto-set z Allegro `invoice.required`; shopPRO heuristic dla 5 znanych kluczy payloadu |
| AC-2: Wystawienie faktury — tryb lokalny | Pass | Numer z `invoice_number_counters` (atomowy), snapshoty zamrozone, Dompdf renderuje fakture VAT |
| AC-3: Wystawienie faktury — tryb delegowany (Fakturownia) | Pass | Smoke test approved przez usera: POST przed INSERT, sukces -> external_invoice_id zapisane, blad -> brak wiersza w invoices + czytelny komunikat |
| AC-4: Lista, podglad i PDF | Pass | `/settings/accounting/invoices/issued` z filtrami; PDF lokalny przez Dompdf, delegowany przez redirect 302 do external_pdf_url |
## Accomplishments
- Operator widzi przycisk "Wystaw fakture" tylko gdy `invoice_requested=1` (auto z importu lub manualnie z zakladki Platnosci)
- Formularz prefilled NIP-em z payload_json zamowienia + "Pobierz z GUS" wypelnia 4 pola firmy z MF Biala Lista jednym klikiem
- Dual flow działa: lokalne faktury z Dompdf PDF, delegowane z natywnym PDF Fakturowni — operator wybiera config, reszta automatyczna
- Lista `/settings/accounting/invoices/issued` z filtrami (search/config/mode/date) i paginacja
- Snapshot pattern: lokalna kopia `seller/buyer/items_json` w `invoices` niezalezna od trybu i przyszlych zmian zrodla
- 5 bug-fixow iteracyjnych w trakcie smoke testu — caly pipeline error reporting/parser uodporniony na PHP 8.5 i edge cases Fakturowni
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| src/Modules/Accounting/InvoiceRepository.php | Created | CRUD + atomowy nextLocalNumber + paginate z filtrami |
| src/Modules/Accounting/InvoiceService.php | Created | Orchestrator dual-flow (lokalny/delegowany) + buildFakturowniaPayload + extractBuyerTaxNumber |
| src/Modules/Accounting/InvoiceController.php | Created | Endpointy create/store/show/pdf/issuedList/nipLookup |
| src/Modules/Accounting/InvoiceIssueException.php | Created | Typed exception dla bledow biznesowych |
| src/Core/Http/MfWhitelistApiClient.php | Created | Klient MF Biala Lista — public API, parser adresu |
| src/Modules/Settings/FakturowniaApiClient.php | Modified | createInvoice() (POST /invoices.json), buildPdfUrl(); usuniete curl_close (PHP 8.5); flatten errors parser |
| src/Modules/Orders/OrdersController.php | Modified | toggleInvoiceRequested AJAX + invoices/invoiceConfigs do view + 2 optional ctor params |
| src/Modules/Orders/OrdersRepository.php | Modified | setInvoiceRequested() |
| src/Modules/Settings/AllegroOrderImportService.php | Modified | Auto-set invoice_requested z payload.invoice.required przy created=true |
| src/Modules/Settings/ShopproOrdersSyncService.php | Modified | shouldRequestInvoice() flexible parser dla 5 znanych kluczy payloadu |
| resources/views/accounting/invoice_form.php | Created | Form wystawiania + "Pobierz z GUS" + confirm dialog dla duplikatu |
| resources/views/accounting/invoice_preview.php | Created | HTML preview faktury z badgem Tryb |
| resources/views/accounting/invoice_pdf.php | Created | Dompdf template "FAKTURA VAT" |
| resources/views/accounting/invoices_issued_list.php | Created | Lista wystawionych z filtrami + paginacja |
| resources/views/orders/show.php | Modified | Toggle w zakladce Platnosci + warunkowy przycisk + sekcja Faktury w tabie Documents |
| resources/views/layouts/app.php | Modified | Rejestracja invoice-requested-toggle.js |
| resources/views/settings/accounting.php | Modified | Link "Faktury wystawione" w karcie Faktury |
| public/assets/js/modules/invoice-requested-toggle.js | Created | Vanilla JS AJAX toggle z idempotent bound guard |
| routes/web.php | Modified | 6 nowych routes + DI wiring InvoiceService/InvoiceController/InvoiceRepository |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Switch GUS lookup na MF Biala Liste | Fakturownia API NIE MA endpointu GUS — bylo wrong assumption. MF Biala Lista publiczna, bez rejestracji, zwraca te same dane (nazwa+adres) | `/api/nip/lookup` zamiast `/api/fakturownia/gus-lookup`; usuniete deps na Fakturownia client w lookupie |
| Nie wysylamy seller_* do Fakturowni | Konta Fakturowni z podwyzszonym security interpretuja roznice w seller_bank_account jako proba "utworz nowy dzial" -> HTTP 422 | Fakturownia uzywa danych konta jako sprzedawca; lokalny snapshot w invoices.seller_data_json zachowany dla audytu |
| Delegated: POST przed INSERT lokalnym | Gwarancja braku orphan rows w `invoices` gdy API padnie | Trade-off: brak idempotencji przy double-POST -> INVOICE-IDEMP-115 w todo.md |
| PHP 8.5: usuniete wszystkie curl_close() | Deprecated od 8.5 (no-op od 8.0), wycieka HTML <br/>Deprecated</b>...</br> przed JSON -> "json is not valid" w fetch().json() | Dwa pliki: MfWhitelistApiClient + FakturowniaApiClient (2 miejsca). ShopproIntegrationsRepository ma to samo (poza zakresem 115) |
| Pominiete department_id w payloadzie | Konta Fakturowni z security level blokuja samo wystepowanie pola jako "create new department" attempt | Fakturownia uzywa domyslnego dzialu konta |
| Auto-set invoice_requested tylko przy created=true | Delta-only re-import (Phase 112) zachowuje stabilnosc manualnej flagi operatora | Manualny toggle nie zostaje nadpisany przez re-import |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 5 | Wszystkie podczas smoke testu - essential, no scope creep |
| Scope additions | 1 | Task 3b (GUS lookup) dodany po user request, potem refactor na MF |
| Deferred | 2 | INVOICE-IDEMP-115 (idempotencja Fakturowni), curl_close w ShopproIntegrationsRepository |
**Total impact:** Essential bugfixes podczas smoke testu (rzeczywiste zachowanie Fakturowni rozne od dokumentacji), plus 1 zaakceptowane scope-extension (NIP lookup z prefillem zamiast manualnego). Bez scope creep.
### Auto-fixed Issues
**1. [GUS] FakturowniaApiClient::lookupClientByNip wolal nieistniejacy endpoint**
- Found during: Task 3b smoke (klik "Pobierz z GUS")
- Issue: URL `/clients/gus.json` zwracal HTML 404, json_decode null
- Fix: Switch na publiczne MF Biala Lista API (`https://wl-api.mf.gov.pl/api/search/nip/{nip}`)
- Files: Created `src/Core/Http/MfWhitelistApiClient.php`, removed lookupClientByNip from FakturowniaApiClient
- Verification: Live test z NIP CD Projekt (7342867148) zwraca poprawne dane firmy
**2. [PHP 8.5] curl_close() wycieka HTML deprecation przed JSON response**
- Found during: Pierwszy smoke test "Pobierz z GUS"
- Issue: `<br/><b>Deprecated</b>: curl_close() in...</b>` pojawialo sie przed JSON -> fetch().json() throws
- Fix: Usuniete `curl_close($ch)` z `httpGet()` w MfWhitelistApiClient i z `httpGet()` + `httpPostJson()` w FakturowniaApiClient
- Files: src/Core/Http/MfWhitelistApiClient.php, src/Modules/Settings/FakturowniaApiClient.php
- Verification: Po fixie response zaczyna sie czystym JSON, fetch parsuje OK
**3. [Fakturownia] payload `payment_to_kind_days` nie istnieje w API**
- Found during: Smoke wystawienia delegowanej faktury
- Issue: HTTP 422 "Nieprawidlowy atrybut: 'payment_to_kind_days'"
- Fix: Usuniete `payment_to_kind` i `payment_to_kind_days` (sama data `payment_to` wystarcza)
- Files: src/Modules/Accounting/InvoiceService.php (buildFakturowniaPayload)
- Verification: Drugi POST nie wraca z tym bledem
**4. [Fakturownia] generyczny `error` bez kontekstu w parserze bledow**
- Found during: Smoke iteracja 2 wystawienia delegowanej
- Issue: `HTTP 422: error` — bez konkretow co odrzucone
- Fix: Parser plaskuje `errors: {pole: [...]}` na `pole: msg1, msg2; pole2: msg3`, fallback do raw body, `error_log()` z 1000 znakow body
- Files: src/Modules/Settings/FakturowniaApiClient.php (resolveErrorMessage + flattenFieldErrors)
- Verification: Kolejny blad pokazal: `{"department":["Poziom zabezpieczenia ... nie pozwala na utworzenie dzialu"]}`
**5. [Fakturownia] seller_* fields triggeruja "create new department" przy security level**
- Found during: Smoke iteracja 3 wystawienia delegowanej
- Issue: HTTP 422 "Poziom zabezpieczenia przed zmiana konta bankowego nie pozwala na utworzenie dzialu" — Fakturownia probowala auto-utworzyc dzial gdy seller_* roznia sie od konta
- Fix: Usuniete VSZYSTKIE pola `seller_*` + `department_id` z payloadu. Fakturownia uzywa danych konta jako sprzedawca. Lokalny snapshot w `invoices.seller_data_json` zachowany dla audytu
- Files: src/Modules/Accounting/InvoiceService.php (buildFakturowniaPayload)
- Verification: Iteracja 4 — wystawienie sie powiodlo, user potwierdzil "Działa."
### Scope Additions
**1. Task 3b: GUS/NIP lookup (post-Task 3 implementacja)**
- Reason: User po smoke teście Task 1-3 zadal pytanie czy Fakturownia API wspiera pobieranie danych z NIP. Odpowiedz: nie bezposrednio. User zatwierdzil dodanie feature poprzez Fakturownia GUS endpoint (okazalo sie nie istnieje), nastepnie zaakceptowal switch na MF Biala Liste
- Scope: 1 nowy client API (`MfWhitelistApiClient`), 1 endpoint controller method (`nipLookup`), 1 route, 1 przycisk + inline JS w invoice_form.php
- Impact: Operator nie musi recznie wpisywac danych firmy po wpisaniu NIP
### Deferred Items
- **INVOICE-IDEMP-115:** Idempotencja podwojnego POST do Fakturowni (gdy response sie nie zwroci, ale faktura w Fakturowni juz powstala) — `.paul/codebase/todo.md`
- **curl_close() w ShopproIntegrationsRepository.php:** Ten sam bug klasy co fix #2, ale poza zakresem Phase 115. Sugerowane jako mini-fix lub Phase 116
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Bootstrap loads but DI wiring fails offline (DB connection refused) | Akceptowalne - XAMPP offline; weryfikacja przez `php -l` + autoload + reflection, smoke test przez usera na zywej bazie |
| Receipt-create.php uzywa positional `OrderProAlerts.confirm(string, callback)` (legacy bug Phase 114) | Invoice form uzywa options-object API zgodnie z memory; receipt-create do osobnej fazy |
| 5 iteracji bug-fix podczas smoke testu | Wszystkie potraktowane jako auto-fix, dokumentowane w deviations, pipeline zostal odporniejszy |
## Skill Audit
| Expected | Invoked | Notes |
|----------|---------|-------|
| (brak required skills w plan frontmatter) | n/a | Plan nie zglaszal SPECIAL-FLOWS requirements |
## Next Phase Readiness
**Ready:**
- Phase 115 zamknieta — wystawianie faktur dziala end-to-end (lokalne + delegowane)
- `MfWhitelistApiClient` w `src/Core/Http/` dostepny dla innych modulow (np. weryfikacja NIP w order_addresses, statusVat check przed wystawieniem)
- Pattern dual-flow (lokalny/delegowany) jako wzorzec dla przyszlych integracji ksiegowych
- `invoice_requested` flaga w `orders` table — moze byc wykorzystana w przyszlych filtrach/raportach
- `invoice_pdf.php` template gotowy do dalszej stylizacji (logo, podpisy itp.) bez zmian backendu
**Concerns:**
- INVOICE-IDEMP-115 — przy braku idempotencji, operator musi recznie weryfikowac w panelu Fakturowni przy bledach sieci
- `curl_close()` w ShopproIntegrationsRepository pozostal — ten sam bug klasy uderzy w shopPRO API calls na PHP 8.5 produkcji
- Brak `invoice.created` automation event (per Phase 113 decision) — wysylka faktury mailem wymaga osobnego mechanizmu
- `seller_data_json` snapshot w lokalnej DB moze odbiegac od danych faktury w Fakturowni (Fakturownia uzywa wlasnego konta) — niespojnosc tylko dla audytu, klient widzi PDF z Fakturowni
**Blockers:**
- None — Phase 115 closed, ready for Phase 116 lub kandydatow z roadmapu (lista faktur juz dolaczona, automation event invoice.created jako kandydat, INVOICE-IDEMP-115 jako tech-debt phase)
---
*Phase: 115-invoice-from-order, Plan: 01*
*Completed: 2026-05-10*

View File

@@ -0,0 +1,62 @@
(function () {
'use strict';
function init() {
var checkbox = document.querySelector('[data-invoice-requested-toggle]');
if (!checkbox || checkbox.dataset.bound === '1') {
return;
}
checkbox.dataset.bound = '1';
var orderId = checkbox.dataset.orderId || '';
var token = checkbox.dataset.csrfToken || '';
var buttonWrap = document.querySelector('[data-invoice-button-wrap]');
checkbox.addEventListener('change', function () {
var newValue = checkbox.checked ? 1 : 0;
checkbox.disabled = true;
var formData = new FormData();
formData.append('_token', token);
formData.append('invoice_requested', String(newValue));
fetch('/orders/' + encodeURIComponent(orderId) + '/invoice-requested/toggle', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData,
credentials: 'same-origin'
})
.then(function (resp) {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
return resp.json();
})
.then(function (data) {
if (!data || !data.success) {
throw new Error(data && data.error ? data.error : 'Blad serwera');
}
if (buttonWrap) {
buttonWrap.style.display = newValue ? '' : 'none';
}
})
.catch(function (err) {
checkbox.checked = !checkbox.checked;
if (window.OrderProAlerts && typeof window.OrderProAlerts.error === 'function') {
window.OrderProAlerts.error('Nie udalo sie zmienic flagi faktury: ' + (err && err.message ? err.message : ''));
} else {
console.error('invoice-requested toggle failed', err);
}
})
.finally(function () {
checkbox.disabled = false;
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,292 @@
<?php
$orderRow = is_array($order ?? null) ? $order : [];
$itemsList = is_array($items ?? null) ? $items : [];
$configsList = is_array($configs ?? null) ? $configs : [];
$sellerData = is_array($seller ?? null) ? $seller : [];
$buyerAddr = is_array($buyerAddress ?? null) ? $buyerAddress : null;
$autoNip = (string) ($autoTaxNumber ?? '');
$existingInvoicesList = is_array($existingInvoices ?? null) ? $existingInvoices : [];
$hasExistingInvoices = $existingInvoicesList !== [];
$orderIdVal = (int) ($orderId ?? 0);
$errorMsg = (string) ($errorMessage ?? '');
$buyerNameDefault = trim((string) ($buyerAddr['name'] ?? ''));
$buyerCompanyDefault = trim((string) ($buyerAddr['company_name'] ?? ''));
$buyerStreetDefault = trim(((string) ($buyerAddr['street_name'] ?? '')) . ' ' . ((string) ($buyerAddr['street_number'] ?? '')));
$buyerCityDefault = trim((string) ($buyerAddr['city'] ?? ''));
$buyerPostalDefault = trim((string) ($buyerAddr['zip_code'] ?? ''));
$buyerEmailDefault = trim((string) ($buyerAddr['email'] ?? $orderRow['buyer_email'] ?? ''));
?>
<section class="card">
<div class="order-details-head">
<div>
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="order-back-link">&larr; Powrot do zamowienia</a>
<h2 class="section-title mt-12">Wystaw fakture</h2>
<div class="order-details-sub mt-4">
Zamowienie <?= $e((string) ($orderRow['internal_order_number'] ?? ('#' . $orderIdVal))) ?>
</div>
</div>
</div>
<?php if ($errorMsg !== ''): ?>
<div class="alert alert--danger mt-12"><?= $e($errorMsg) ?></div>
<?php endif; ?>
<?php if ($hasExistingInvoices): ?>
<div class="alert alert--warning mt-12">
<strong>Uwaga!</strong> Do tego zamowienia wystawiono juz <?= $e((string) count($existingInvoicesList)) ?> fakture/y:
<ul class="mt-4">
<?php foreach ($existingInvoicesList as $ei): ?>
<li>
<strong><?= $e((string) ($ei['invoice_number'] ?? '-')) ?></strong>
— <?= $e(substr((string) ($ei['issue_date'] ?? ''), 0, 16)) ?>,
<?= $e(number_format((float) ($ei['total_gross'] ?? 0), 2, '.', ' ')) ?> PLN
(<?= $e((string) ($ei['config_name'] ?? '-')) ?><?php if (trim((string) ($ei['external_invoice_id'] ?? '')) !== ''): ?>, Fakturownia<?php endif; ?>)
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form id="invoice-create-form" method="post" action="/orders/<?= $e((string) $orderIdVal) ?>/invoice/store" class="mt-16">
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
<div class="form-grid-2">
<div class="form-group">
<label class="form-label" for="config_id">Konfiguracja faktury</label>
<select name="config_id" id="config_id" class="form-control" required>
<option value="">— wybierz konfiguracje —</option>
<?php foreach ($configsList as $cfg): ?>
<?php
$cfgId = (int) ($cfg['id'] ?? 0);
$cfgIsDelegated = (int) ($cfg['is_delegated'] ?? 0) === 1;
$cfgIntName = trim((string) ($cfg['integration_name'] ?? ''));
$cfgLabel = trim((string) ($cfg['name'] ?? ''))
. ' — '
. ($cfgIsDelegated ? ('Fakturownia' . ($cfgIntName !== '' ? ': ' . $cfgIntName : '')) : 'Lokalnie')
. ' (' . trim((string) ($cfg['number_format'] ?? '')) . ')';
?>
<option value="<?= $e((string) $cfgId) ?>"><?= $e($cfgLabel) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label" for="issue_date">Data wystawienia</label>
<input type="datetime-local" name="issue_date" id="issue_date" class="form-control" value="<?= $e(date('Y-m-d\TH:i')) ?>" required>
</div>
</div>
<h3 class="section-title mt-16">Dane nabywcy</h3>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label" for="buyer_tax_number">NIP nabywcy</label>
<div style="display:flex;gap:8px;align-items:flex-start;">
<input type="text" name="buyer_tax_number" id="buyer_tax_number" class="form-control"
value="<?= $e($autoNip) ?>"
placeholder="np. 1234567890"
style="flex:1;">
<button type="button" id="btn-gus-lookup" class="btn btn--secondary" style="white-space:nowrap;">
Pobierz z GUS
</button>
</div>
<?php if ($autoNip !== ''): ?>
<small class="muted">Auto-wykryty z payload zamowienia. Mozesz nadpisac lub kliknac "Pobierz z GUS".</small>
<?php else: ?>
<small class="muted">Wpisz NIP i kliknij "Pobierz z GUS" — dane firmy zostana wypelnione automatycznie.</small>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="buyer_name">Imie i nazwisko</label>
<input type="text" name="buyer_name" id="buyer_name" class="form-control"
value="<?= $e($buyerNameDefault) ?>">
</div>
<div class="form-group">
<label class="form-label" for="buyer_company_name">Nazwa firmy</label>
<input type="text" name="buyer_company_name" id="buyer_company_name" class="form-control"
value="<?= $e($buyerCompanyDefault) ?>">
</div>
<div class="form-group">
<label class="form-label" for="buyer_email">Email</label>
<input type="email" name="buyer_email" id="buyer_email" class="form-control"
value="<?= $e($buyerEmailDefault) ?>">
</div>
<div class="form-group">
<label class="form-label" for="buyer_street">Ulica i numer</label>
<input type="text" name="buyer_street" id="buyer_street" class="form-control"
value="<?= $e($buyerStreetDefault) ?>">
</div>
<div class="form-group">
<label class="form-label" for="buyer_city">Miejscowosc</label>
<input type="text" name="buyer_city" id="buyer_city" class="form-control"
value="<?= $e($buyerCityDefault) ?>">
</div>
<div class="form-group">
<label class="form-label" for="buyer_postal_code">Kod pocztowy</label>
<input type="text" name="buyer_postal_code" id="buyer_postal_code" class="form-control"
value="<?= $e($buyerPostalDefault) ?>">
</div>
</div>
<h3 class="section-title mt-16">Pozycje zamowienia</h3>
<div class="table-wrap mt-8">
<table class="table table--details">
<thead>
<tr>
<th>Lp.</th>
<th>Nazwa</th>
<th>Ilosc</th>
<th>Cena brutto</th>
<th>VAT</th>
<th>Suma brutto</th>
</tr>
</thead>
<tbody>
<?php if ($itemsList === []): ?>
<tr><td colspan="6" class="muted">Brak pozycji</td></tr>
<?php endif; ?>
<?php $totalGross = 0.0; ?>
<?php foreach ($itemsList as $idx => $item): ?>
<?php
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : (float) ($item['price_gross'] ?? 0);
$vat = (float) ($item['vat'] ?? 23);
$sum = $qty * $price;
$totalGross += $sum;
?>
<tr>
<td><?= $e((string) ($idx + 1)) ?></td>
<td><?= $e((string) ($item['original_name'] ?? $item['name'] ?? '')) ?></td>
<td><?= $e((string) $qty) ?></td>
<td><?= $e(number_format($price, 2, '.', ' ')) ?></td>
<td><?= $e(number_format($vat, 0, '.', '')) ?>%</td>
<td><?= $e(number_format($sum, 2, '.', ' ')) ?></td>
</tr>
<?php endforeach; ?>
<?php
$deliveryPrice = (float) ($orderRow['delivery_price'] ?? 0);
if ($deliveryPrice > 0) {
$totalGross += $deliveryPrice;
?>
<tr>
<td><?= $e((string) (count($itemsList) + 1)) ?></td>
<td>Koszt wysylki</td>
<td>1</td>
<td><?= $e(number_format($deliveryPrice, 2, '.', ' ')) ?></td>
<td>23%</td>
<td><?= $e(number_format($deliveryPrice, 2, '.', ' ')) ?></td>
</tr>
<?php } ?>
</tbody>
<tfoot>
<tr>
<td colspan="5" class="text-right"><strong>Razem brutto:</strong></td>
<td><strong><?= $e(number_format($totalGross, 2, '.', ' ')) ?> <?= $e((string) ($orderRow['currency'] ?? 'PLN')) ?></strong></td>
</tr>
</tfoot>
</table>
</div>
<h3 class="section-title mt-16">Sprzedawca (z ustawien firmy)</h3>
<div class="receipt-seller-preview mt-8">
<dl class="order-kv">
<dt>Firma</dt><dd><?= $e((string) ($sellerData['company_name'] ?? '-')) ?></dd>
<dt>NIP</dt><dd><?= $e((string) ($sellerData['tax_number'] ?? '-')) ?></dd>
<dt>Adres</dt><dd><?= $e((string) ($sellerData['street'] ?? '')) ?>, <?= $e((string) ($sellerData['postal_code'] ?? '')) ?> <?= $e((string) ($sellerData['city'] ?? '')) ?></dd>
</dl>
</div>
<div class="mt-16">
<?php if ($hasExistingInvoices): ?>
<button type="button" id="invoice-submit-btn" class="btn btn--primary">Wystaw fakture</button>
<?php else: ?>
<button type="submit" class="btn btn--primary">Wystaw fakture</button>
<?php endif; ?>
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="btn btn--secondary ml-8">Anuluj</a>
</div>
</form>
</section>
<script>
(function () {
var btn = document.getElementById('btn-gus-lookup');
if (!btn) { return; }
btn.addEventListener('click', function () {
var nipInput = document.getElementById('buyer_tax_number');
var nip = (nipInput.value || '').replace(/[\s\-]/g, '');
if (!/^\d{10}$/.test(nip)) {
if (window.OrderProAlerts && window.OrderProAlerts.error) {
window.OrderProAlerts.error('Wpisz poprawny NIP (10 cyfr) przed klikniem "Pobierz z GUS".');
}
return;
}
var originalLabel = btn.textContent;
btn.disabled = true;
btn.textContent = 'Pobieram...';
var url = '/api/nip/lookup?nip=' + encodeURIComponent(nip);
fetch(url, {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin'
})
.then(function (resp) { return resp.json().then(function (j) { return { ok: resp.ok, data: j }; }); })
.then(function (res) {
if (!res.ok || !res.data || !res.data.success) {
var msg = res.data && res.data.error ? res.data.error : 'Blad pobierania danych z GUS.';
throw new Error(msg);
}
var d = res.data.data || {};
if (d.tax_number) { nipInput.value = d.tax_number; }
var fields = {
buyer_company_name: d.company_name,
buyer_street: d.street,
buyer_postal_code: d.postal_code,
buyer_city: d.city
};
Object.keys(fields).forEach(function (key) {
var el = document.getElementById(key);
if (el && fields[key]) { el.value = fields[key]; }
});
})
.catch(function (err) {
if (window.OrderProAlerts && window.OrderProAlerts.error) {
window.OrderProAlerts.error(err && err.message ? err.message : 'Blad GUS.');
} else {
alert(err && err.message ? err.message : 'Blad GUS.');
}
})
.finally(function () {
btn.disabled = false;
btn.textContent = originalLabel;
});
});
})();
</script>
<?php if ($hasExistingInvoices): ?>
<script>
document.getElementById('invoice-submit-btn').addEventListener('click', function() {
window.OrderProAlerts.confirm({
title: 'Wystawic kolejna fakture?',
message: 'Do tego zamowienia wystawiono juz fakture. Czy na pewno chcesz wystawic kolejna?',
confirmLabel: 'Wystaw',
danger: false,
onConfirm: function() {
document.getElementById('invoice-create-form').submit();
}
});
});
</script>
<?php endif; ?>

View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<title>Faktura <?= $e((string) ($invoice['invoice_number'] ?? '')) ?></title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "DejaVu Sans", "Segoe UI", Arial, sans-serif; font-size: 12px; color: #1a1a1a; padding: 20mm 15mm; }
.invoice-print { max-width: 740px; margin: 0 auto; }
.invoice-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 2px solid #333; }
.invoice-header__seller { flex: 1; }
.invoice-header__seller strong { font-size: 14px; display: block; margin-bottom: 4px; }
.invoice-header__title { text-align: right; }
.invoice-header__title h1 { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.invoice-header__title .invoice-number { font-size: 14px; font-weight: 600; }
.invoice-parties { display: flex; gap: 12px; margin-bottom: 16px; }
.invoice-party { flex: 1; padding: 8px 12px; border: 1px solid #ccc; }
.invoice-party strong { display: block; margin-bottom: 4px; font-size: 11px; text-transform: uppercase; color: #666; }
.invoice-items { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
.invoice-items th { background: #f0f0f0; text-align: left; padding: 6px 8px; font-size: 11px; border-bottom: 1px solid #999; }
.invoice-items td { padding: 5px 8px; border-bottom: 1px solid #ddd; }
.invoice-items .text-right { text-align: right; }
.invoice-items .text-nowrap { white-space: nowrap; }
.invoice-items tfoot td { border-top: 1px solid #999; font-weight: 600; padding: 6px 8px; }
.invoice-items tfoot .total-row td { border-top: 2px solid #333; font-weight: 700; }
.invoice-meta { margin-top: 16px; font-size: 11px; color: #555; }
.invoice-meta dt { display: inline; font-weight: 600; }
.invoice-meta dd { display: inline; margin: 0 16px 0 4px; }
.invoice-payment { margin-top: 16px; padding: 8px 12px; border: 1px solid #ccc; background: #fafafa; }
</style>
</head>
<body>
<?php
$invoiceData = is_array($invoice ?? null) ? $invoice : [];
$sellerData = is_array($seller ?? null) ? $seller : [];
$buyerData = is_array($buyer ?? null) ? $buyer : null;
$itemsList = is_array($items ?? null) ? $items : [];
$totalGross = (float) ($invoiceData['total_gross'] ?? 0);
$totalNet = (float) ($invoiceData['total_net'] ?? 0);
$totalVat = max(0.0, $totalGross - $totalNet);
?>
<div class="invoice-print">
<div class="invoice-header">
<div class="invoice-header__seller">
<strong><?= $e((string) ($sellerData['company_name'] ?? '')) ?></strong>
<?php if (($sellerData['tax_number'] ?? '') !== ''): ?>
<div>NIP: <?= $e((string) $sellerData['tax_number']) ?></div>
<?php endif; ?>
<div><?= $e((string) ($sellerData['street'] ?? '')) ?></div>
<div><?= $e((string) ($sellerData['postal_code'] ?? '')) ?> <?= $e((string) ($sellerData['city'] ?? '')) ?></div>
<?php if (($sellerData['phone'] ?? '') !== ''): ?>
<div>Tel: <?= $e((string) $sellerData['phone']) ?></div>
<?php endif; ?>
<?php if (($sellerData['email'] ?? '') !== ''): ?>
<div>Email: <?= $e((string) $sellerData['email']) ?></div>
<?php endif; ?>
</div>
<div class="invoice-header__title">
<h1>FAKTURA VAT</h1>
<div class="invoice-number"><?= $e((string) ($invoiceData['invoice_number'] ?? '')) ?></div>
<?php $issueDate = (string) ($invoiceData['issue_date'] ?? ''); ?>
<div>Data wystawienia: <?= $e(substr($issueDate, 0, 10)) ?></div>
<?php $saleDate = (string) ($invoiceData['sale_date'] ?? ''); ?>
<?php if ($saleDate !== ''): ?>
<div>Data sprzedazy: <?= $e(substr($saleDate, 0, 10)) ?></div>
<?php endif; ?>
</div>
</div>
<?php if ($buyerData !== null): ?>
<div class="invoice-parties">
<div class="invoice-party">
<strong>Sprzedawca</strong>
<div><?= $e((string) ($sellerData['company_name'] ?? '')) ?></div>
<?php if (($sellerData['tax_number'] ?? '') !== ''): ?>
<div>NIP: <?= $e((string) $sellerData['tax_number']) ?></div>
<?php endif; ?>
<div><?= $e((string) ($sellerData['street'] ?? '')) ?></div>
<div><?= $e((string) ($sellerData['postal_code'] ?? '')) ?> <?= $e((string) ($sellerData['city'] ?? '')) ?></div>
</div>
<div class="invoice-party">
<strong>Nabywca</strong>
<?php if (($buyerData['company_name'] ?? '') !== ''): ?>
<div><?= $e((string) $buyerData['company_name']) ?></div>
<?php endif; ?>
<?php if (($buyerData['name'] ?? '') !== ''): ?>
<div><?= $e((string) $buyerData['name']) ?></div>
<?php endif; ?>
<?php if (($buyerData['tax_number'] ?? '') !== ''): ?>
<div>NIP: <?= $e((string) $buyerData['tax_number']) ?></div>
<?php endif; ?>
<div><?= $e((string) ($buyerData['street'] ?? '')) ?></div>
<div><?= $e((string) ($buyerData['postal_code'] ?? '')) ?> <?= $e((string) ($buyerData['city'] ?? '')) ?></div>
</div>
</div>
<?php endif; ?>
<table class="invoice-items">
<thead>
<tr>
<th>Lp.</th>
<th>Nazwa</th>
<th class="text-right">Ilosc</th>
<th class="text-right">Cena netto</th>
<th class="text-right">VAT</th>
<th class="text-right">Cena brutto</th>
<th class="text-right">Suma brutto</th>
</tr>
</thead>
<tbody>
<?php foreach ($itemsList as $idx => $item): ?>
<tr>
<td><?= $e((string) ($idx + 1)) ?></td>
<td><?= $e((string) ($item['name'] ?? '')) ?></td>
<td class="text-right text-nowrap"><?= $e((string) ($item['quantity'] ?? 0)) ?></td>
<td class="text-right text-nowrap"><?= $e(number_format((float) ($item['price_net'] ?? 0), 2, '.', ' ')) ?></td>
<td class="text-right"><?= $e(number_format((float) ($item['vat'] ?? 0), 0, '.', '')) ?>%</td>
<td class="text-right text-nowrap"><?= $e(number_format((float) ($item['price_gross'] ?? 0), 2, '.', ' ')) ?></td>
<td class="text-right text-nowrap"><?= $e(number_format((float) ($item['total_gross'] ?? 0), 2, '.', ' ')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right">Razem netto</td>
<td class="text-right text-nowrap"><?= $e(number_format($totalNet, 2, '.', ' ')) ?></td>
</tr>
<tr>
<td colspan="6" class="text-right">Razem VAT</td>
<td class="text-right text-nowrap"><?= $e(number_format($totalVat, 2, '.', ' ')) ?></td>
</tr>
<tr class="total-row">
<td colspan="6" class="text-right">Razem brutto</td>
<td class="text-right text-nowrap"><?= $e(number_format($totalGross, 2, '.', ' ')) ?> PLN</td>
</tr>
</tfoot>
</table>
<?php if (($invoiceData['payment_due_date'] ?? null) !== null): ?>
<div class="invoice-payment">
<strong>Termin platnosci:</strong> <?= $e(substr((string) $invoiceData['payment_due_date'], 0, 10)) ?>
<?php if (trim((string) ($sellerData['bank_account'] ?? '')) !== ''): ?>
| <strong>Konto:</strong> <?= $e((string) $sellerData['bank_account']) ?>
<?php endif; ?>
</div>
<?php endif; ?>
<dl class="invoice-meta">
<?php if (($invoiceData['order_reference_value'] ?? null) !== null): ?>
<dt>Nr referencyjny:</dt><dd><?= $e((string) $invoiceData['order_reference_value']) ?></dd>
<?php endif; ?>
<dt>Typ dokumentu:</dt><dd><?= $e((string) ($invoiceData['kind'] ?? 'vat')) ?></dd>
</dl>
</div>
</body>
</html>

View File

@@ -0,0 +1,131 @@
<?php
$invoiceData = is_array($invoice ?? null) ? $invoice : [];
$sellerData = is_array($seller ?? null) ? $seller : [];
$buyerData = is_array($buyer ?? null) ? $buyer : null;
$itemsList = is_array($items ?? null) ? $items : [];
$orderIdVal = (int) ($orderId ?? 0);
$configNameVal = (string) ($configName ?? '');
$invoiceNumber = (string) ($invoiceData['invoice_number'] ?? '');
$totalGross = (float) ($invoiceData['total_gross'] ?? 0);
$totalNet = (float) ($invoiceData['total_net'] ?? 0);
$isDelegatedFlag = (bool) ($isDelegated ?? false);
$externalIdVal = trim((string) ($invoiceData['external_invoice_id'] ?? ''));
$externalPdfVal = trim((string) ($invoiceData['external_pdf_url'] ?? ''));
?>
<section class="card">
<div class="order-details-head">
<div>
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="order-back-link">&larr; Powrot do zamowienia</a>
<h2 class="section-title mt-12">Faktura <?= $e($invoiceNumber) ?></h2>
<div class="order-details-sub mt-4">
<?php if ($isDelegatedFlag): ?>
<span class="badge badge--success">Wystawione w Fakturowni<?php if (trim((string) ($accountPrefix ?? '')) !== ''): ?>: <?= $e((string) $accountPrefix) ?><?php endif; ?></span>
<?php if ($externalIdVal !== ''): ?> <span class="muted">(id zewnetrzne: <?= $e($externalIdVal) ?>)</span><?php endif; ?>
<?php else: ?>
<span class="badge badge--muted">Wystawione lokalnie</span>
<?php endif; ?>
</div>
</div>
<div class="order-details-head__actions">
<?php if ($isDelegatedFlag && $externalPdfVal !== ''): ?>
<a href="/orders/<?= $e((string) $orderIdVal) ?>/invoice/<?= $e((string) ($invoiceData['id'] ?? '')) ?>/pdf" class="btn btn--primary" target="_blank" rel="noopener">PDF (Fakturownia)</a>
<?php else: ?>
<a href="/orders/<?= $e((string) $orderIdVal) ?>/invoice/<?= $e((string) ($invoiceData['id'] ?? '')) ?>/pdf" class="btn btn--primary">Pobierz PDF</a>
<?php endif; ?>
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="btn btn--secondary">Powrot</a>
</div>
</div>
<div class="form-grid-2 mt-16">
<div>
<h3 class="section-title">Sprzedawca</h3>
<dl class="order-kv mt-8">
<dt>Firma</dt><dd><?= $e((string) ($sellerData['company_name'] ?? '-')) ?></dd>
<dt>NIP</dt><dd><?= $e((string) ($sellerData['tax_number'] ?? '-')) ?></dd>
<dt>Adres</dt><dd><?= $e((string) ($sellerData['street'] ?? '')) ?>, <?= $e((string) ($sellerData['postal_code'] ?? '')) ?> <?= $e((string) ($sellerData['city'] ?? '')) ?></dd>
<dt>Telefon</dt><dd><?= $e((string) ($sellerData['phone'] ?? '-')) ?></dd>
<dt>Email</dt><dd><?= $e((string) ($sellerData['email'] ?? '-')) ?></dd>
<?php if (trim((string) ($sellerData['bank_account'] ?? '')) !== ''): ?>
<dt>Konto</dt><dd><?= $e((string) $sellerData['bank_account']) ?></dd>
<?php endif; ?>
</dl>
</div>
<?php if ($buyerData !== null): ?>
<div>
<h3 class="section-title">Nabywca</h3>
<dl class="order-kv mt-8">
<?php if (($buyerData['company_name'] ?? '') !== ''): ?>
<dt>Firma</dt><dd><?= $e((string) $buyerData['company_name']) ?></dd>
<?php endif; ?>
<?php if (($buyerData['name'] ?? '') !== ''): ?>
<dt>Nazwa</dt><dd><?= $e((string) $buyerData['name']) ?></dd>
<?php endif; ?>
<?php if (($buyerData['tax_number'] ?? '') !== ''): ?>
<dt>NIP</dt><dd><?= $e((string) $buyerData['tax_number']) ?></dd>
<?php endif; ?>
<dt>Adres</dt><dd><?= $e((string) ($buyerData['street'] ?? '')) ?>, <?= $e((string) ($buyerData['postal_code'] ?? '')) ?> <?= $e((string) ($buyerData['city'] ?? '')) ?></dd>
<?php if (($buyerData['email'] ?? '') !== ''): ?>
<dt>Email</dt><dd><?= $e((string) $buyerData['email']) ?></dd>
<?php endif; ?>
</dl>
</div>
<?php endif; ?>
</div>
<h3 class="section-title mt-16">Pozycje</h3>
<div class="table-wrap mt-8">
<table class="table table--details">
<thead>
<tr>
<th>Lp.</th>
<th>Nazwa</th>
<th>Ilosc</th>
<th>Cena netto</th>
<th>VAT</th>
<th>Cena brutto</th>
<th>Suma brutto</th>
</tr>
</thead>
<tbody>
<?php foreach ($itemsList as $idx => $item): ?>
<tr>
<td><?= $e((string) ($idx + 1)) ?></td>
<td><?= $e((string) ($item['name'] ?? '')) ?></td>
<td><?= $e((string) ($item['quantity'] ?? 0)) ?></td>
<td class="text-nowrap"><?= $e(number_format((float) ($item['price_net'] ?? 0), 2, '.', ' ')) ?></td>
<td><?= $e(number_format((float) ($item['vat'] ?? 0), 0, '.', '')) ?>%</td>
<td class="text-nowrap"><?= $e(number_format((float) ($item['price_gross'] ?? 0), 2, '.', ' ')) ?></td>
<td class="text-nowrap"><?= $e(number_format((float) ($item['total_gross'] ?? 0), 2, '.', ' ')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right"><strong>Razem netto</strong></td>
<td class="text-nowrap"><strong><?= $e(number_format($totalNet, 2, '.', ' ')) ?></strong></td>
</tr>
<tr>
<td colspan="6" class="text-right"><strong>Razem brutto</strong></td>
<td class="text-nowrap"><strong><?= $e(number_format($totalGross, 2, '.', ' ')) ?> PLN</strong></td>
</tr>
</tfoot>
</table>
</div>
<div class="form-grid-2 mt-16">
<dl class="order-kv">
<?php $issueDateShow = (string) ($invoiceData['issue_date'] ?? '-'); ?>
<dt>Data wystawienia</dt><dd><?= $e(strlen($issueDateShow) >= 16 ? substr($issueDateShow, 0, 16) : $issueDateShow) ?></dd>
<dt>Data sprzedazy</dt><dd><?= $e((string) ($invoiceData['sale_date'] ?? '-')) ?></dd>
<?php if (($invoiceData['payment_due_date'] ?? null) !== null): ?>
<dt>Termin platnosci</dt><dd><?= $e(substr((string) $invoiceData['payment_due_date'], 0, 10)) ?></dd>
<?php endif; ?>
<dt>Konfiguracja</dt><dd><?= $e($configNameVal !== '' ? $configNameVal : '-') ?></dd>
<dt>Typ</dt><dd><?= $e((string) ($invoiceData['kind'] ?? 'vat')) ?></dd>
<?php if (($invoiceData['order_reference_value'] ?? null) !== null): ?>
<dt>Nr referencyjny</dt><dd><?= $e((string) $invoiceData['order_reference_value']) ?></dd>
<?php endif; ?>
</dl>
</div>
</section>

View File

@@ -0,0 +1,144 @@
<?php
$invoicesList = is_array($invoices ?? null) ? $invoices : [];
$configsList = is_array($configs ?? null) ? $configs : [];
$filtersData = is_array($filters ?? null) ? $filters : [];
$totalCount = (int) ($total ?? 0);
$pageNum = (int) ($page ?? 1);
$totalPagesNum = (int) ($totalPages ?? 1);
$perPageNum = (int) ($perPage ?? 50);
?>
<section class="card">
<div class="orders-head">
<div>
<a href="/settings/accounting" class="order-back-link">&larr; Ksiegowosc</a>
<h2 class="section-title mt-12">Wystawione faktury</h2>
<div class="muted mt-4">Lacznie: <?= $e((string) $totalCount) ?></div>
</div>
</div>
<form method="get" action="/settings/accounting/invoices/issued" class="mt-12">
<div class="form-grid-2">
<div class="form-group">
<label class="form-label" for="search">Szukaj (numer, zamowienie)</label>
<input type="text" name="search" id="search" class="form-control" value="<?= $e((string) ($filtersData['search'] ?? '')) ?>">
</div>
<div class="form-group">
<label class="form-label" for="config_id">Konfiguracja</label>
<select name="config_id" id="config_id" class="form-control">
<option value="0">Wszystkie</option>
<?php foreach ($configsList as $cfg): ?>
<option value="<?= $e((string) ($cfg['id'] ?? '')) ?>" <?= ((int) ($filtersData['config_id'] ?? 0) === (int) ($cfg['id'] ?? 0)) ? 'selected' : '' ?>>
<?= $e((string) ($cfg['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label" for="mode">Tryb</label>
<select name="mode" id="mode" class="form-control">
<?php $modeVal = (string) ($filtersData['mode'] ?? ''); ?>
<option value="" <?= $modeVal === '' ? 'selected' : '' ?>>Wszystkie</option>
<option value="local" <?= $modeVal === 'local' ? 'selected' : '' ?>>Lokalnie</option>
<option value="delegated" <?= $modeVal === 'delegated' ? 'selected' : '' ?>>Fakturownia</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="date_from">Data od</label>
<input type="date" name="date_from" id="date_from" class="form-control" value="<?= $e((string) ($filtersData['date_from'] ?? '')) ?>">
</div>
<div class="form-group">
<label class="form-label" for="date_to">Data do</label>
<input type="date" name="date_to" id="date_to" class="form-control" value="<?= $e((string) ($filtersData['date_to'] ?? '')) ?>">
</div>
</div>
<div class="mt-12">
<button type="submit" class="btn btn--primary">Filtruj</button>
<a href="/settings/accounting/invoices/issued" class="btn btn--secondary ml-8">Wyczysc</a>
</div>
</form>
<div class="table-wrap mt-16">
<table class="table">
<thead>
<tr>
<th>Numer</th>
<th>Data wystawienia</th>
<th>Nabywca</th>
<th>Brutto</th>
<th>Tryb</th>
<th>Konfiguracja</th>
<th>Zamowienie</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
<?php if ($invoicesList === []): ?>
<tr><td colspan="8" class="muted">Brak faktur w wybranych filtrach</td></tr>
<?php endif; ?>
<?php foreach ($invoicesList as $inv): ?>
<?php
$buyer = json_decode((string) ($inv['buyer_data_json'] ?? '{}'), true);
$buyer = is_array($buyer) ? $buyer : [];
$buyerLabel = trim((string) ($buyer['company_name'] ?? '')) !== ''
? (string) $buyer['company_name']
: (string) ($buyer['name'] ?? '-');
$buyerNip = trim((string) ($buyer['tax_number'] ?? ''));
$isDelegated = trim((string) ($inv['external_invoice_id'] ?? '')) !== '';
$issueDate = (string) ($inv['issue_date'] ?? '');
$orderRef = (int) ($inv['order_id'] ?? 0);
$orderLabel = trim((string) ($inv['internal_order_number'] ?? '')) !== ''
? (string) $inv['internal_order_number']
: ('#' . $orderRef);
?>
<tr>
<td><strong><?= $e((string) ($inv['invoice_number'] ?? '')) ?></strong></td>
<td class="text-nowrap"><?= $e(strlen($issueDate) >= 16 ? substr($issueDate, 0, 16) : $issueDate) ?></td>
<td>
<?= $e($buyerLabel) ?>
<?php if ($buyerNip !== ''): ?>
<div class="muted">NIP: <?= $e($buyerNip) ?></div>
<?php endif; ?>
</td>
<td class="text-nowrap"><?= $e(number_format((float) ($inv['total_gross'] ?? 0), 2, '.', ' ')) ?> PLN</td>
<td>
<?php if ($isDelegated): ?>
<span class="badge badge--success">Fakturownia<?php if (trim((string) ($inv['account_prefix'] ?? '')) !== ''): ?>: <?= $e((string) $inv['account_prefix']) ?><?php endif; ?></span>
<?php else: ?>
<span class="badge badge--muted">Lokalnie</span>
<?php endif; ?>
</td>
<td><?= $e((string) ($inv['config_name'] ?? '-')) ?></td>
<td><a href="/orders/<?= $e((string) $orderRef) ?>"><?= $e($orderLabel) ?></a></td>
<td>
<a href="/orders/<?= $e((string) $orderRef) ?>/invoice/<?= $e((string) ($inv['id'] ?? '')) ?>" class="btn btn--sm btn--secondary">Podglad</a>
<?php if ($isDelegated && trim((string) ($inv['external_pdf_url'] ?? '')) !== ''): ?>
<a href="/orders/<?= $e((string) $orderRef) ?>/invoice/<?= $e((string) ($inv['id'] ?? '')) ?>/pdf" class="btn btn--sm btn--secondary" target="_blank" rel="noopener">PDF</a>
<?php else: ?>
<a href="/orders/<?= $e((string) $orderRef) ?>/invoice/<?= $e((string) ($inv['id'] ?? '')) ?>/pdf" class="btn btn--sm btn--secondary">PDF</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($totalPagesNum > 1): ?>
<div class="pagination mt-12">
<?php for ($p = 1; $p <= $totalPagesNum; $p++): ?>
<?php
$qs = http_build_query(array_merge(
$filtersData,
['page' => $p]
));
?>
<?php if ($p === $pageNum): ?>
<strong class="pagination__current"><?= $e((string) $p) ?></strong>
<?php else: ?>
<a class="pagination__page" href="/settings/accounting/invoices/issued?<?= $e($qs) ?>"><?= $e((string) $p) ?></a>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
</section>

View File

@@ -192,6 +192,7 @@
<script src="/assets/js/modules/global-search.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/global-search.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/checkbox-multiselect.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/checkbox-multiselect.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/invoice-config-form.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/invoice-config-form.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/invoice-requested-toggle.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/invoice-requested-toggle.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/confirm-delete.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/confirm-delete.js') ?: 0 ?>"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
<script src="/assets/js/modules/statistics-summary-charts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/statistics-summary-charts.js') ?: 0 ?>"></script>

View File

@@ -9,6 +9,9 @@ $documentsList = is_array($documents ?? null) ? $documents : [];
$notesList = is_array($notes ?? null) ? $notes : [];
$receiptsList = is_array($receipts ?? null) ? $receipts : [];
$receiptConfigsList = is_array($receiptConfigs ?? null) ? $receiptConfigs : [];
$invoicesList = is_array($invoices ?? null) ? $invoices : [];
$invoiceConfigsList = is_array($invoiceConfigs ?? null) ? $invoiceConfigs : [];
$invoiceRequestedFlag = (int) ($orderRow['invoice_requested'] ?? 0) === 1;
$emailTemplatesList = is_array($emailTemplates ?? null) ? $emailTemplates : [];
$emailMailboxesList = is_array($emailMailboxes ?? null) ? $emailMailboxes : [];
$historyList = is_array($history ?? null) ? $history : [];
@@ -61,6 +64,13 @@ foreach ($addressesList as $address) {
<?php if ($receiptConfigsList !== []): ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/receipt/create" class="btn btn--secondary">Wystaw paragon</a>
<?php endif; ?>
<span data-invoice-button-wrap style="<?= $invoiceRequestedFlag ? '' : 'display:none;' ?>">
<?php if ($invoiceConfigsList !== []): ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/invoice/create" class="btn btn--secondary">Wystaw fakture</a>
<?php else: ?>
<button type="button" class="btn btn--secondary btn--disabled" title="Brak aktywnych konfiguracji faktur">Wystaw fakture</button>
<?php endif; ?>
</span>
<?php
$emailBuyerAddr = '';
foreach ($addressesList as $a) {
@@ -712,6 +722,18 @@ foreach ($addressesList as $address) {
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.payments')) ?></h3>
<div class="invoice-requested-row mt-12" style="display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--border-color, #e5e7eb);border-radius:6px;">
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;margin:0;">
<input type="checkbox"
data-invoice-requested-toggle
data-order-id="<?= $e((string) ($orderId ?? 0)) ?>"
data-csrf-token="<?= $e((string) ($csrfToken ?? '')) ?>"
<?= $invoiceRequestedFlag ? 'checked' : '' ?>>
<span><strong>Klient prosi o fakture</strong></span>
</label>
<span class="muted" style="font-size:12px;">Po zaznaczeniu pojawi sie przycisk "Wystaw fakture" w naglowku zamowienia.</span>
</div>
<?php
$paymentStatusNum = isset($orderRow['payment_status']) ? (int) $orderRow['payment_status'] : null;
$paymentStatusLabels = [0 => 'Nieopłacone', 1 => 'Częściowo opłacone', 2 => 'Opłacone', 3 => 'Zwrócone'];
@@ -843,9 +865,56 @@ foreach ($addressesList as $address) {
<div class="order-tab-panel" data-order-tab-panel="documents">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.documents')) ?></h3>
<?php if ($receiptsList === [] && $documentsList === []): ?>
<?php if ($receiptsList === [] && $documentsList === [] && $invoicesList === []): ?>
<p class="muted mt-12"><?= $e($t('receipts.documents.empty')) ?></p>
<?php endif; ?>
<?php if ($invoicesList !== []): ?>
<h4 class="section-title mt-12">Faktury</h4>
<div class="table-wrap mt-8">
<table class="table table--details">
<thead>
<tr>
<th>Numer</th>
<th>Data wystawienia</th>
<th>Brutto</th>
<th>Tryb</th>
<th>Konfiguracja</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
<?php foreach ($invoicesList as $inv): ?>
<?php
$invIssueDate = (string) ($inv['issue_date'] ?? '');
$invIsDelegated = trim((string) ($inv['external_invoice_id'] ?? '')) !== '';
$invExternalPdf = trim((string) ($inv['external_pdf_url'] ?? ''));
?>
<tr>
<td><strong><?= $e((string) ($inv['invoice_number'] ?? '')) ?></strong></td>
<td class="text-nowrap"><?= $e(strlen($invIssueDate) >= 16 ? substr($invIssueDate, 0, 16) : $invIssueDate) ?></td>
<td class="text-nowrap"><?= $e($inv['total_gross'] !== null ? number_format((float) $inv['total_gross'], 2, '.', ' ') : '-') ?></td>
<td>
<?php if ($invIsDelegated): ?>
<span class="badge badge--success">Fakturownia<?php if (trim((string) ($inv['account_prefix'] ?? '')) !== ''): ?>: <?= $e((string) $inv['account_prefix']) ?><?php endif; ?></span>
<?php else: ?>
<span class="badge badge--muted">Lokalnie</span>
<?php endif; ?>
</td>
<td><?= $e((string) ($inv['config_name'] ?? '-')) ?></td>
<td>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/invoice/<?= $e((string) ($inv['id'] ?? '')) ?>" class="btn btn--sm btn--secondary">Podglad</a>
<?php if ($invIsDelegated && $invExternalPdf !== ''): ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/invoice/<?= $e((string) ($inv['id'] ?? '')) ?>/pdf" class="btn btn--sm btn--secondary" target="_blank" rel="noopener">PDF</a>
<?php else: ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/invoice/<?= $e((string) ($inv['id'] ?? '')) ?>/pdf" class="btn btn--sm btn--secondary">PDF</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ($receiptsList !== []): ?>
<h4 class="section-title mt-12">Paragony</h4>
<div class="table-wrap mt-8">

View File

@@ -29,6 +29,7 @@ $error = trim((string) ($errorMessage ?? ''));
<p class="muted mt-12">Konfiguracje wystawiania faktur: numeracja lokalna lub delegacja do Fakturowni, termin platnosci, typ dokumentu.</p>
<div class="form-actions mt-16">
<a class="btn btn--primary" href="/settings/accounting/invoices">Zarzadzaj fakturami</a>
<a class="btn btn--secondary ml-8" href="/settings/accounting/invoices/issued">Faktury wystawione</a>
</div>
</div>
</div>

View File

@@ -55,6 +55,10 @@ use App\Modules\Email\AttachmentGenerator;
use App\Modules\Email\EmailSendingService;
use App\Modules\Email\VariableResolver;
use App\Modules\Accounting\AccountingController;
use App\Core\Http\MfWhitelistApiClient;
use App\Modules\Accounting\InvoiceController;
use App\Modules\Accounting\InvoiceRepository;
use App\Modules\Accounting\InvoiceService;
use App\Modules\Accounting\ReceiptController;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Accounting\ReceiptService;
@@ -243,6 +247,7 @@ return static function (Application $app): void {
$invoiceConfigRepository,
$fakturowniaIntegrationRepository
);
$invoiceRepository = new InvoiceRepository($app->db());
$emailMailboxRepository = new EmailMailboxRepository(
$app->db(),
new IntegrationSecretCipher((string) $app->config('app.integrations.secret', ''))
@@ -287,6 +292,14 @@ return static function (Application $app): void {
$companySettingsRepository,
new OrdersRepository($app->db())
);
$invoiceService = new InvoiceService(
$invoiceRepository,
$invoiceConfigRepository,
$companySettingsRepository,
new OrdersRepository($app->db()),
$fakturowniaIntegrationRepository,
$fakturowniaApiClient
);
$automationService = new AutomationService(
$automationRepository,
$automationExecutionLogRepository,
@@ -324,7 +337,7 @@ return static function (Application $app): void {
$allegroDeliveryMappingController
);
$printJobRepository = new PrintJobRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService);
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository);
$ordersStatisticsController = new OrdersStatisticsController(
$template,
$translator,
@@ -349,6 +362,17 @@ return static function (Application $app): void {
$receiptRepository,
$receiptConfigRepository
);
$invoiceController = new InvoiceController(
$template,
$translator,
$auth,
$invoiceRepository,
$invoiceConfigRepository,
$companySettingsRepository,
new OrdersRepository($app->db()),
$invoiceService,
new MfWhitelistApiClient()
);
$allegroApiClient = new AllegroApiClient();
$shipmentPackageRepository = new ShipmentPackageRepository($app->db());
$shipmentService = new AllegroShipmentService(
@@ -586,6 +610,14 @@ return static function (Application $app): void {
$router->get('/orders/{id}/receipt/{receiptId}', [$receiptController, 'show'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/{receiptId}/print', [$receiptController, 'printView'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/{receiptId}/pdf', [$receiptController, 'pdf'], [$authMiddleware]);
// Invoices from order (Phase 115-01)
$router->post('/orders/{id}/invoice-requested/toggle', [$ordersController, 'toggleInvoiceRequested'], [$authMiddleware]);
$router->get('/orders/{id}/invoice/create', [$invoiceController, 'create'], [$authMiddleware]);
$router->post('/orders/{id}/invoice/store', [$invoiceController, 'store'], [$authMiddleware]);
$router->get('/orders/{id}/invoice/{invoiceId}', [$invoiceController, 'show'], [$authMiddleware]);
$router->get('/orders/{id}/invoice/{invoiceId}/pdf', [$invoiceController, 'pdf'], [$authMiddleware]);
$router->get('/settings/accounting/invoices/issued', [$invoiceController, 'issuedList'], [$authMiddleware]);
$router->get('/api/nip/lookup', [$invoiceController, 'nipLookup'], [$authMiddleware]);
$router->get('/orders/{id}/shipment/prepare', [$shipmentController, 'prepare'], [$authMiddleware]);
$router->post('/orders/{id}/shipment/create', [$shipmentController, 'create'], [$authMiddleware]);
$router->get('/orders/{id}/shipment/{packageId}/status', [$shipmentController, 'checkStatus'], [$authMiddleware]);

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Core\Http;
use RuntimeException;
/**
* Klient publicznego API "Biala lista podatnikow VAT" Ministerstwa Finansow.
* Endpoint: https://wl-api.mf.gov.pl/api/search/nip/{nip}?date=YYYY-MM-DD
* Bez rejestracji, bez klucza. Limit ~10 req/s per IP.
*/
final class MfWhitelistApiClient
{
private const BASE_URL = 'https://wl-api.mf.gov.pl';
public function __construct(private readonly int $timeoutSeconds = 10)
{
}
/**
* @return array{name: string, tax_no: string, regon: string, street: string, postal_code: string, city: string, country: string, status_vat: string, raw: array<string, mixed>}
*/
public function lookupByNip(string $nip, ?string $date = null): array
{
$cleanNip = preg_replace('/[\s\-]/', '', trim($nip)) ?? '';
if (!preg_match('/^\d{10}$/', $cleanNip)) {
throw new RuntimeException('NIP musi miec 10 cyfr.');
}
$dateParam = $date ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateParam)) {
$dateParam = date('Y-m-d');
}
$url = self::BASE_URL . '/api/search/nip/' . rawurlencode($cleanNip)
. '?date=' . rawurlencode($dateParam);
[$body, $httpCode, $curlError] = $this->httpGet($url);
if ($curlError !== null) {
throw new RuntimeException('Blad polaczenia z MF Biala Lista: ' . $curlError);
}
if ($httpCode < 200 || $httpCode >= 300) {
$msg = $this->resolveErrorMessage($body);
throw new RuntimeException('MF Biala Lista HTTP ' . $httpCode . ($msg !== '' ? ': ' . $msg : ''));
}
$decoded = json_decode($body, true);
if (!is_array($decoded) || !is_array($decoded['result'] ?? null)) {
throw new RuntimeException('Niepoprawna odpowiedz JSON z MF Biala Lista.');
}
$subject = is_array($decoded['result']['subject'] ?? null) ? $decoded['result']['subject'] : null;
if ($subject === null) {
throw new RuntimeException('MF Biala Lista nie zwrocila danych dla NIP ' . $cleanNip . ' (brak podmiotu).');
}
$name = trim((string) ($subject['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('MF Biala Lista nie zwrocila nazwy dla NIP ' . $cleanNip . '.');
}
$address = trim((string) ($subject['workingAddress'] ?? $subject['residenceAddress'] ?? ''));
['street' => $street, 'postal_code' => $postalCode, 'city' => $city] = $this->parseAddress($address);
return [
'name' => $name,
'tax_no' => trim((string) ($subject['nip'] ?? $cleanNip)),
'regon' => trim((string) ($subject['regon'] ?? '')),
'street' => $street,
'postal_code' => $postalCode,
'city' => $city,
'country' => 'PL',
'status_vat' => trim((string) ($subject['statusVat'] ?? '')),
'raw' => $subject,
];
}
/**
* Parse address from MF Biala Lista format: "ul. Krakowska 1, 00-001 Warszawa".
*
* @return array{street: string, postal_code: string, city: string}
*/
private function parseAddress(string $address): array
{
if ($address === '') {
return ['street' => '', 'postal_code' => '', 'city' => ''];
}
$lastComma = strrpos($address, ',');
if ($lastComma === false) {
return ['street' => $address, 'postal_code' => '', 'city' => ''];
}
$street = trim(substr($address, 0, $lastComma));
$tail = trim(substr($address, $lastComma + 1));
if (preg_match('/^(\d{2}-\d{3})\s+(.+)$/u', $tail, $m)) {
return ['street' => $street, 'postal_code' => $m[1], 'city' => trim($m[2])];
}
return ['street' => $street, 'postal_code' => '', 'city' => $tail];
}
/**
* @return array{0: string, 1: int, 2: ?string}
*/
private function httpGet(string $url): array
{
$ch = curl_init($url);
if ($ch === false) {
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
}
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPGET => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'User-Agent: orderPRO/1.0',
],
];
$caPath = SslCertificateResolver::resolve();
if ($caPath !== null) {
$opts[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($ch, $opts);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
}
return [(string) $rawBody, $httpCode, null];
}
private function resolveErrorMessage(string $body): string
{
$trimmed = ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B");
if ($trimmed === '') {
return '';
}
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
foreach (['message', 'code', 'error'] as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
return trim($decoded[$key]);
}
}
}
$snippet = trim(strip_tags($trimmed));
return substr($snippet, 0, 200);
}
}

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Orders\OrdersRepository;
use App\Core\Http\MfWhitelistApiClient;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\InvoiceConfigRepository;
use Throwable;
final class InvoiceController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly InvoiceRepository $invoices,
private readonly InvoiceConfigRepository $invoiceConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders,
private readonly InvoiceService $invoiceService,
private readonly MfWhitelistApiClient $mfWhitelist
) {
}
public function nipLookup(Request $request): Response
{
$nip = preg_replace('/[\s\-]/', '', (string) $request->input('nip', '')) ?? '';
if (!preg_match('/^\d{10}$/', $nip)) {
return Response::json(['success' => false, 'error' => 'Niepoprawny NIP (musi miec 10 cyfr).'], 422);
}
try {
$data = $this->mfWhitelist->lookupByNip($nip);
} catch (Throwable $e) {
return Response::json(['success' => false, 'error' => $e->getMessage()], 502);
}
return Response::json([
'success' => true,
'data' => [
'company_name' => $data['name'],
'tax_number' => $data['tax_no'],
'street' => $data['street'],
'postal_code' => $data['postal_code'],
'city' => $data['city'],
'country' => $data['country'],
'regon' => $data['regon'],
'status_vat' => $data['status_vat'],
],
]);
}
public function create(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return Response::html('Not found', 404);
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
if ((int) ($order['invoice_requested'] ?? 0) !== 1) {
Flash::set('order.error', 'Faktura nie zostala zazadana dla tego zamowienia.');
return Response::redirect('/orders/' . $orderId);
}
$configs = array_values(array_filter(
$this->invoiceConfigs->listAll(),
static fn (array $c): bool => (int) ($c['is_active'] ?? 0) === 1
));
if ($configs === []) {
Flash::set('order.error', 'Brak aktywnych konfiguracji faktur. Skonfiguruj w Ustawienia > Ksiegowosc > Faktury.');
return Response::redirect('/orders/' . $orderId);
}
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$byType = [];
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type !== '' && !isset($byType[$type])) {
$byType[$type] = $addr;
}
}
$buyerAddress = $byType['invoice'] ?? $byType['customer'] ?? null;
$autoTaxNumber = InvoiceService::extractBuyerTaxNumber($order, $buyerAddress);
$existingInvoices = $this->invoices->findByOrderId($orderId);
$html = $this->template->render('accounting/invoice_form', [
'title' => 'Wystaw fakture',
'activeMenu' => 'orders',
'activeOrders' => 'list',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'orderId' => $orderId,
'order' => $order,
'items' => $items,
'configs' => $configs,
'seller' => $this->companySettings->getSettings(),
'buyerAddress' => $buyerAddress,
'autoTaxNumber' => $autoTaxNumber,
'existingInvoices' => $existingInvoices,
'errorMessage' => (string) Flash::get('invoice.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function store(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', 'Nieprawidlowy token CSRF.');
return Response::redirect('/orders/' . $orderId);
}
$configId = (int) $request->input('config_id', 0);
if ($configId <= 0) {
Flash::set('invoice.error', 'Wybierz konfiguracje faktury.');
return Response::redirect('/orders/' . $orderId . '/invoice/create');
}
$user = $this->auth->user();
try {
$result = $this->invoiceService->issue([
'order_id' => $orderId,
'config_id' => $configId,
'buyer_tax_number' => (string) $request->input('buyer_tax_number', ''),
'buyer_name' => (string) $request->input('buyer_name', ''),
'buyer_company_name' => (string) $request->input('buyer_company_name', ''),
'buyer_street' => (string) $request->input('buyer_street', ''),
'buyer_city' => (string) $request->input('buyer_city', ''),
'buyer_postal_code' => (string) $request->input('buyer_postal_code', ''),
'buyer_email' => (string) $request->input('buyer_email', ''),
'issue_date_override' => (string) $request->input('issue_date', ''),
'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
]);
$userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
$this->orders->recordActivity(
$orderId,
'invoice_issued',
($result['mode'] === 'delegated' ? 'Wystawiono fakture (Fakturownia): ' : 'Wystawiono fakture: ') . $result['invoice_number'],
[
'invoice_number' => $result['invoice_number'],
'config_id' => $configId,
'mode' => $result['mode'],
'total_gross' => $result['total_gross'],
],
'user',
$userName !== '' ? $userName : null
);
Flash::set('order.success', 'Faktura wystawiona: ' . $result['invoice_number']);
return Response::redirect('/orders/' . $orderId . '/invoice/' . $result['invoice_id']);
} catch (InvoiceIssueException $e) {
Flash::set('invoice.error', $e->getMessage());
} catch (Throwable $e) {
Flash::set('invoice.error', 'Blad wystawiania faktury: ' . $e->getMessage());
}
return Response::redirect('/orders/' . $orderId . '/invoice/create');
}
public function show(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$invoiceId = max(0, (int) $request->input('invoiceId', 0));
$invoice = $this->invoices->findById($invoiceId);
if ($invoice === null || (int) ($invoice['order_id'] ?? 0) !== $orderId) {
return Response::html('Not found', 404);
}
$data = $this->buildInvoiceViewData($invoice, $orderId);
$html = $this->template->render('accounting/invoice_preview', $data, 'layouts/app');
return Response::html($html);
}
public function pdf(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$invoiceId = max(0, (int) $request->input('invoiceId', 0));
$invoice = $this->invoices->findById($invoiceId);
if ($invoice === null || (int) ($invoice['order_id'] ?? 0) !== $orderId) {
return Response::html('Not found', 404);
}
$externalPdfUrl = trim((string) ($invoice['external_pdf_url'] ?? ''));
if ($externalPdfUrl !== '') {
return Response::redirect($externalPdfUrl);
}
$data = $this->buildInvoiceViewData($invoice, $orderId);
$html = $this->template->render('accounting/invoice_pdf', $data);
$dompdf = new \Dompdf\Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$filename = str_replace(['/', '\\'], '_', (string) ($invoice['invoice_number'] ?? 'invoice')) . '.pdf';
return new Response($dompdf->output() ?: '', 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
public function issuedList(Request $request): Response
{
$filters = [
'search' => trim((string) $request->input('search', '')),
'config_id' => (int) $request->input('config_id', 0),
'mode' => (string) $request->input('mode', ''),
'date_from' => trim((string) $request->input('date_from', '')),
'date_to' => trim((string) $request->input('date_to', '')),
'page' => max(1, (int) $request->input('page', 1)),
'per_page' => 50,
];
$result = $this->invoices->paginate($filters);
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$configs = $this->invoiceConfigs->listAll();
$html = $this->template->render('accounting/invoices_issued_list', [
'title' => 'Wystawione faktury',
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'filters' => $filters,
'invoices' => $result['items'],
'total' => $result['total'],
'page' => $result['page'],
'perPage' => $result['per_page'],
'totalPages' => $totalPages,
'configs' => $configs,
], 'layouts/app');
return Response::html($html);
}
/**
* @param array<string, mixed> $invoice
* @return array<string, mixed>
*/
private function buildInvoiceViewData(array $invoice, int $orderId): array
{
$seller = json_decode((string) ($invoice['seller_data_json'] ?? '{}'), true);
$buyer = ($invoice['buyer_data_json'] ?? null) !== null
? json_decode((string) $invoice['buyer_data_json'], true)
: null;
$itemsJson = json_decode((string) ($invoice['items_json'] ?? '{"items":[]}'), true);
$items = is_array($itemsJson['items'] ?? null) ? $itemsJson['items'] : (is_array($itemsJson) ? $itemsJson : []);
$configName = trim((string) ($invoice['config_name'] ?? ''));
$isDelegated = (int) ($invoice['config_is_delegated'] ?? 0) === 1
|| trim((string) ($invoice['external_invoice_id'] ?? '')) !== '';
return [
'title' => 'Faktura ' . ($invoice['invoice_number'] ?? ''),
'activeMenu' => 'orders',
'activeOrders' => 'list',
'user' => $this->auth->user(),
'orderId' => $orderId,
'invoice' => $invoice,
'seller' => is_array($seller) ? $seller : [],
'buyer' => is_array($buyer) ? $buyer : null,
'items' => $items,
'configName' => $configName,
'isDelegated' => $isDelegated,
'integrationName' => trim((string) ($invoice['integration_name'] ?? '')),
'accountPrefix' => trim((string) ($invoice['account_prefix'] ?? '')),
];
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use RuntimeException;
final class InvoiceIssueException extends RuntimeException
{
}

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use PDO;
final class InvoiceRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function findByOrderId(int $orderId): array
{
$statement = $this->pdo->prepare(
'SELECT i.*, ic.name AS config_name, ic.is_delegated AS config_is_delegated,
ig.name AS integration_name, fis.account_prefix
FROM invoices i
LEFT JOIN invoice_configs ic ON ic.id = i.config_id
LEFT JOIN integrations ig ON ig.id = ic.integration_id AND ig.type = "fakturownia"
LEFT JOIN fakturownia_integration_settings fis ON fis.integration_id = ig.id
WHERE i.order_id = :order_id
ORDER BY i.created_at DESC'
);
$statement->execute(['order_id' => $orderId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT i.*, ic.name AS config_name, ic.is_delegated AS config_is_delegated,
ig.name AS integration_name, fis.account_prefix
FROM invoices i
LEFT JOIN invoice_configs ic ON ic.id = i.config_id
LEFT JOIN integrations ig ON ig.id = ic.integration_id AND ig.type = "fakturownia"
LEFT JOIN fakturownia_integration_settings fis ON fis.integration_id = ig.id
WHERE i.id = :id LIMIT 1'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $data
*/
public function insertLocal(array $data): int
{
return $this->insert($data, isDelegated: false);
}
/**
* @param array<string, mixed> $data
*/
public function insertDelegated(array $data): int
{
return $this->insert($data, isDelegated: true);
}
/**
* @param array<string, mixed> $data
*/
private function insert(array $data, bool $isDelegated): int
{
$statement = $this->pdo->prepare(
'INSERT INTO invoices (
order_id, config_id, invoice_number, issue_date, sale_date, payment_due_date,
seller_data_json, buyer_data_json, items_json,
total_net, total_gross, order_reference_value,
external_invoice_id, external_pdf_url, kind, created_by
) VALUES (
:order_id, :config_id, :invoice_number, :issue_date, :sale_date, :payment_due_date,
:seller_data_json, :buyer_data_json, :items_json,
:total_net, :total_gross, :order_reference_value,
:external_invoice_id, :external_pdf_url, :kind, :created_by
)'
);
$statement->execute([
'order_id' => (int) $data['order_id'],
'config_id' => (int) $data['config_id'],
'invoice_number' => (string) $data['invoice_number'],
'issue_date' => (string) $data['issue_date'],
'sale_date' => (string) $data['sale_date'],
'payment_due_date' => $data['payment_due_date'] ?? null,
'seller_data_json' => (string) $data['seller_data_json'],
'buyer_data_json' => $data['buyer_data_json'],
'items_json' => (string) $data['items_json'],
'total_net' => (string) $data['total_net'],
'total_gross' => (string) $data['total_gross'],
'order_reference_value' => $data['order_reference_value'] ?? null,
'external_invoice_id' => $isDelegated ? ($data['external_invoice_id'] ?? null) : null,
'external_pdf_url' => $isDelegated ? ($data['external_pdf_url'] ?? null) : null,
'kind' => (string) ($data['kind'] ?? 'vat'),
'created_by' => $data['created_by'] ?? null,
]);
return (int) $this->pdo->lastInsertId();
}
public function nextLocalNumber(int $configId, string $numberFormat, string $numberingType): string
{
$year = (int) date('Y');
$month = $numberingType === 'yearly' ? null : (int) date('n');
if ($month === null) {
$this->pdo->prepare(
'INSERT INTO invoice_number_counters (config_id, year, month, last_number)
VALUES (:config_id, :year, NULL, 1)
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
)->execute(['config_id' => $configId, 'year' => $year]);
$stmt = $this->pdo->prepare(
'SELECT last_number FROM invoice_number_counters
WHERE config_id = :config_id AND year = :year AND month IS NULL'
);
$stmt->execute(['config_id' => $configId, 'year' => $year]);
} else {
$this->pdo->prepare(
'INSERT INTO invoice_number_counters (config_id, year, month, last_number)
VALUES (:config_id, :year, :month, 1)
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
)->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
$stmt = $this->pdo->prepare(
'SELECT last_number FROM invoice_number_counters
WHERE config_id = :config_id AND year = :year AND month = :month'
);
$stmt->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
}
$lastNumber = (int) $stmt->fetchColumn();
return str_replace(
['%N', '%M', '%Y'],
[
str_pad((string) $lastNumber, 3, '0', STR_PAD_LEFT),
str_pad((string) ($month ?? 1), 2, '0', STR_PAD_LEFT),
(string) $year,
],
$numberFormat
);
}
/**
* @param array<string, mixed> $filters
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
public function paginate(array $filters): array
{
$where = [];
$params = [];
$configId = (int) ($filters['config_id'] ?? 0);
if ($configId > 0) {
$where[] = 'i.config_id = :config_id';
$params['config_id'] = $configId;
}
$mode = (string) ($filters['mode'] ?? '');
if ($mode === 'local') {
$where[] = 'i.external_invoice_id IS NULL';
} elseif ($mode === 'delegated') {
$where[] = 'i.external_invoice_id IS NOT NULL';
}
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
if ($dateFrom !== '' && strtotime($dateFrom) !== false) {
$where[] = 'i.issue_date >= :date_from';
$params['date_from'] = $dateFrom;
}
$dateTo = trim((string) ($filters['date_to'] ?? ''));
if ($dateTo !== '' && strtotime($dateTo) !== false) {
$where[] = 'i.issue_date <= :date_to';
$params['date_to'] = $dateTo . ' 23:59:59';
}
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(i.invoice_number LIKE :search OR o.internal_order_number LIKE :search2 OR o.external_order_id LIKE :search3)';
$params['search'] = '%' . $search . '%';
$params['search2'] = '%' . $search . '%';
$params['search3'] = '%' . $search . '%';
}
$whereClause = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
$countStmt = $this->pdo->prepare(
"SELECT COUNT(*) FROM invoices i
LEFT JOIN orders o ON o.id = i.order_id
{$whereClause}"
);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 50)));
$offset = ($page - 1) * $perPage;
$stmt = $this->pdo->prepare(
"SELECT i.*, ic.name AS config_name, ic.is_delegated AS config_is_delegated,
ig.name AS integration_name, fis.account_prefix,
o.internal_order_number, o.external_order_id
FROM invoices i
LEFT JOIN invoice_configs ic ON ic.id = i.config_id
LEFT JOIN integrations ig ON ig.id = ic.integration_id AND ig.type = 'fakturownia'
LEFT JOIN fakturownia_integration_settings fis ON fis.integration_id = ig.id
LEFT JOIN orders o ON o.id = i.order_id
{$whereClause}
ORDER BY i.issue_date DESC, i.id DESC
LIMIT {$perPage} OFFSET {$offset}"
);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return [
'items' => is_array($rows) ? $rows : [],
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
}

View File

@@ -0,0 +1,611 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\FakturowniaApiClient;
use App\Modules\Settings\FakturowniaIntegrationRepository;
use App\Modules\Settings\InvoiceConfigRepository;
use Throwable;
final class InvoiceService
{
public function __construct(
private readonly InvoiceRepository $invoices,
private readonly InvoiceConfigRepository $invoiceConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders,
private readonly FakturowniaIntegrationRepository $fakturownia,
private readonly FakturowniaApiClient $fakturowniaApi
) {
}
/**
* @param array{
* order_id: int,
* config_id: int,
* buyer_tax_number?: string,
* buyer_name?: string,
* buyer_company_name?: string,
* buyer_street?: string,
* buyer_city?: string,
* buyer_postal_code?: string,
* buyer_email?: string,
* issue_date_override?: string,
* created_by?: int|null,
* } $params
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
* @throws InvoiceIssueException
*/
public function issue(array $params): array
{
$orderId = (int) $params['order_id'];
$configId = (int) $params['config_id'];
$config = $this->invoiceConfigs->findById($configId);
if ($config === null || (int) ($config['is_active'] ?? 0) !== 1) {
throw new InvoiceIssueException('Nieprawidlowa lub nieaktywna konfiguracja faktury.');
}
$details = $this->orders->findDetails($orderId);
if ($details === null) {
throw new InvoiceIssueException('Zamowienie nie istnieje.');
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
$issueDate = $this->resolveIssueDate((string) ($params['issue_date_override'] ?? ''));
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate);
$paymentDueDate = $this->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7));
$orderReference = $this->resolveOrderReference($config, $order);
$sellerSnapshot = $this->buildSellerSnapshot();
$buyerSnapshot = $this->buildBuyerSnapshot($order, $addresses, $params);
['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->buildItemsSnapshot($items, $order);
$kind = trim((string) ($config['default_kind'] ?? 'vat')) ?: 'vat';
$isDelegated = (int) ($config['is_delegated'] ?? 0) === 1;
if ($isDelegated) {
return $this->issueDelegated(
$orderId,
$configId,
$config,
$issueDate,
$saleDate,
$paymentDueDate,
$sellerSnapshot,
$buyerSnapshot,
$itemsSnapshot,
$totalNet,
$totalGross,
$orderReference,
$kind,
$params['created_by'] ?? null
);
}
return $this->issueLocal(
$orderId,
$configId,
$config,
$issueDate,
$saleDate,
$paymentDueDate,
$sellerSnapshot,
$buyerSnapshot,
$itemsSnapshot,
$totalNet,
$totalGross,
$orderReference,
$kind,
$params['created_by'] ?? null
);
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
*/
private function issueLocal(
int $orderId,
int $configId,
array $config,
string $issueDate,
string $saleDate,
string $paymentDueDate,
array $sellerSnapshot,
?array $buyerSnapshot,
array $itemsSnapshot,
float $totalNet,
float $totalGross,
string $orderReference,
string $kind,
?int $createdBy
): array {
$invoiceNumber = $this->invoices->nextLocalNumber(
$configId,
(string) ($config['number_format'] ?? 'FV/%N/%M/%Y'),
(string) ($config['numbering_type'] ?? 'monthly')
);
$invoiceId = $this->invoices->insertLocal([
'order_id' => $orderId,
'config_id' => $configId,
'invoice_number' => $invoiceNumber,
'issue_date' => $issueDate,
'sale_date' => $saleDate,
'payment_due_date' => $paymentDueDate,
'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
'total_net' => number_format($totalNet, 2, '.', ''),
'total_gross' => number_format($totalGross, 2, '.', ''),
'order_reference_value' => $orderReference !== '' ? $orderReference : null,
'kind' => $kind,
'created_by' => $createdBy,
]);
return [
'invoice_id' => $invoiceId,
'invoice_number' => $invoiceNumber,
'total_gross' => number_format($totalGross, 2, '.', ''),
'mode' => 'local',
];
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
*/
private function issueDelegated(
int $orderId,
int $configId,
array $config,
string $issueDate,
string $saleDate,
string $paymentDueDate,
array $sellerSnapshot,
?array $buyerSnapshot,
array $itemsSnapshot,
float $totalNet,
float $totalGross,
string $orderReference,
string $kind,
?int $createdBy
): array {
$integrationId = (int) ($config['integration_id'] ?? 0);
if ($integrationId <= 0) {
throw new InvoiceIssueException('Konfiguracja delegowana nie wskazuje konta Fakturowni.');
}
$account = $this->fakturownia->findByIntegrationId($integrationId);
if ($account === null) {
throw new InvoiceIssueException('Konto Fakturownia nie istnieje (id=' . $integrationId . ').');
}
$prefix = trim((string) ($account['account_prefix'] ?? ''));
if ($prefix === '') {
throw new InvoiceIssueException('Konto Fakturownia nie ma ustawionego prefiksu (subdomeny).');
}
$apiToken = $this->fakturownia->getDecryptedToken($integrationId);
if ($apiToken === null || $apiToken === '') {
throw new InvoiceIssueException('Brak tokenu API dla konta Fakturownia.');
}
$payload = $this->buildFakturowniaPayload(
$kind,
$issueDate,
$saleDate,
$paymentDueDate,
$sellerSnapshot,
$buyerSnapshot,
$itemsSnapshot,
$orderReference,
(int) ($config['payment_to_days'] ?? 7),
(string) ($account['department_id'] ?? '')
);
try {
$response = $this->fakturowniaApi->createInvoice([
'account_prefix' => $prefix,
'api_token' => $apiToken,
], $payload);
} catch (Throwable $e) {
throw new InvoiceIssueException('Fakturownia: ' . $e->getMessage());
}
$externalId = trim((string) ($response['id'] ?? ''));
$externalNumber = trim((string) ($response['number'] ?? ''));
$externalPdfUrl = trim((string) ($response['pdf_url'] ?? $response['view_url'] ?? ''));
if ($externalId === '' || $externalNumber === '') {
throw new InvoiceIssueException('Fakturownia zwrocila niekompletna odpowiedz (brak id/number).');
}
$invoiceId = $this->invoices->insertDelegated([
'order_id' => $orderId,
'config_id' => $configId,
'invoice_number' => $externalNumber,
'issue_date' => $issueDate,
'sale_date' => $saleDate,
'payment_due_date' => $paymentDueDate,
'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
'total_net' => number_format($totalNet, 2, '.', ''),
'total_gross' => number_format($totalGross, 2, '.', ''),
'order_reference_value' => $orderReference !== '' ? $orderReference : null,
'external_invoice_id' => $externalId,
'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null,
'kind' => $kind,
'created_by' => $createdBy,
]);
return [
'invoice_id' => $invoiceId,
'invoice_number' => $externalNumber,
'total_gross' => number_format($totalGross, 2, '.', ''),
'mode' => 'delegated',
];
}
private function resolveIssueDate(string $override): string
{
$override = trim($override);
if ($override !== '' && strtotime($override) !== false) {
return date('Y-m-d H:i:s', (int) strtotime($override));
}
return date('Y-m-d H:i:s');
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $payments
*/
private function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string
{
$source = (string) ($config['sale_date_source'] ?? 'issue_date');
if ($source === 'order_date') {
$ordered = trim((string) ($order['external_created_at'] ?? $order['ordered_at'] ?? ''));
if ($ordered !== '') {
$ts = strtotime($ordered);
return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate;
}
}
if ($source === 'payment_date' && $payments !== []) {
$payment = $payments[0] ?? [];
$payDate = trim((string) ($payment['payment_date'] ?? ''));
if ($payDate !== '') {
$ts = strtotime($payDate);
return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate;
}
}
return $issueDate;
}
private function resolvePaymentDueDate(string $issueDate, int $paymentToDays): string
{
$ts = strtotime($issueDate);
if ($ts === false) {
$ts = time();
}
return date('Y-m-d 00:00:00', $ts + max(0, $paymentToDays) * 86400);
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
*/
private function resolveOrderReference(array $config, array $order): string
{
$ref = (string) ($config['order_reference'] ?? 'none');
if ($ref === 'orderpro') {
return (string) ($order['internal_order_number'] ?? '');
}
if ($ref === 'integration') {
return (string) ($order['external_order_number'] ?? $order['external_order_id'] ?? '');
}
return '';
}
/**
* @return array<string, mixed>
*/
private function buildSellerSnapshot(): array
{
$seller = $this->companySettings->getSettings();
return [
'company_name' => $seller['company_name'] ?? '',
'tax_number' => $seller['tax_number'] ?? '',
'street' => $seller['street'] ?? '',
'city' => $seller['city'] ?? '',
'postal_code' => $seller['postal_code'] ?? '',
'phone' => $seller['phone'] ?? '',
'email' => $seller['email'] ?? '',
'bank_account' => $seller['bank_account'] ?? '',
'bdo_number' => $seller['bdo_number'] ?? '',
'regon' => $seller['regon'] ?? '',
'court_register' => $seller['court_register'] ?? '',
];
}
/**
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $addresses
* @param array<string, mixed> $params
* @return array<string, mixed>|null
*/
private function buildBuyerSnapshot(array $order, array $addresses, array $params): ?array
{
$byType = [];
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type !== '' && !isset($byType[$type])) {
$byType[$type] = $addr;
}
}
$buyerAddress = $byType['invoice'] ?? $byType['customer'] ?? null;
$autoTaxNumber = self::extractBuyerTaxNumber($order, $buyerAddress);
$manualTax = trim((string) ($params['buyer_tax_number'] ?? ''));
$manualName = trim((string) ($params['buyer_name'] ?? ''));
$manualCompany = trim((string) ($params['buyer_company_name'] ?? ''));
$manualStreet = trim((string) ($params['buyer_street'] ?? ''));
$manualCity = trim((string) ($params['buyer_city'] ?? ''));
$manualPostal = trim((string) ($params['buyer_postal_code'] ?? ''));
$manualEmail = trim((string) ($params['buyer_email'] ?? ''));
$name = $manualName !== ''
? $manualName
: trim((string) ($buyerAddress['name'] ?? ''));
$company = $manualCompany !== ''
? $manualCompany
: trim((string) ($buyerAddress['company_name'] ?? ''));
$street = $manualStreet !== ''
? $manualStreet
: trim(((string) ($buyerAddress['street_name'] ?? '')) . ' ' . ((string) ($buyerAddress['street_number'] ?? '')));
$city = $manualCity !== ''
? $manualCity
: trim((string) ($buyerAddress['city'] ?? ''));
$postal = $manualPostal !== ''
? $manualPostal
: trim((string) ($buyerAddress['zip_code'] ?? ''));
$email = $manualEmail !== ''
? $manualEmail
: trim((string) ($buyerAddress['email'] ?? $order['buyer_email'] ?? ''));
$taxNumber = $manualTax !== '' ? $manualTax : $autoTaxNumber;
if ($name === '' && $company === '' && $taxNumber === '' && $street === '') {
return null;
}
return [
'name' => $name,
'company_name' => $company,
'tax_number' => $taxNumber,
'street' => $street,
'city' => $city,
'postal_code' => $postal,
'phone' => trim((string) ($buyerAddress['phone'] ?? '')),
'email' => $email,
];
}
/**
* Extract NIP from various payload locations (Allegro, shopPRO).
*
* @param array<string, mixed> $order
* @param array<string, mixed>|null $buyerAddress
*/
public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string
{
if ($buyerAddress !== null) {
$candidate = trim((string) ($buyerAddress['company_tax_number'] ?? ''));
if ($candidate !== '') {
return $candidate;
}
}
$payload = $order['payload_json'] ?? null;
if (is_string($payload) && $payload !== '') {
$decoded = json_decode($payload, true);
if (is_array($decoded)) {
foreach (self::taxNumberPaths() as $path) {
$value = self::digValue($decoded, $path);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
}
return '';
}
/**
* @return list<list<string>>
*/
private static function taxNumberPaths(): array
{
return [
['invoice', 'address', 'taxId'],
['invoice', 'taxId'],
['invoice', 'nip'],
['buyer', 'tax_number'],
['buyer', 'nip'],
['client', 'nip'],
['client', 'tax_number'],
['nip'],
['tax_number'],
];
}
/**
* @param array<string, mixed> $arr
* @param list<string> $path
*/
private static function digValue(array $arr, array $path): mixed
{
$cur = $arr;
foreach ($path as $key) {
if (!is_array($cur) || !array_key_exists($key, $cur)) {
return null;
}
$cur = $cur[$key];
}
return $cur;
}
/**
* @param list<array<string, mixed>> $items
* @param array<string, mixed> $order
* @return array{items: list<array<string, mixed>>, total_gross: float, total_net: float}
*/
private function buildItemsSnapshot(array $items, array $order): array
{
$itemsSnapshot = [];
$totalGross = 0.0;
$totalNet = 0.0;
foreach ($items as $item) {
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : (float) ($item['price_gross'] ?? 0);
$vat = (float) ($item['vat'] ?? 23);
$lineGross = $qty * $price;
$lineNet = $vat > 0 ? round($lineGross / (1 + $vat / 100), 2) : $lineGross;
$totalGross += $lineGross;
$totalNet += $lineNet;
$itemsSnapshot[] = [
'name' => $item['original_name'] ?? $item['name'] ?? '',
'quantity' => $qty,
'price_gross' => $price,
'price_net' => $vat > 0 ? round($price / (1 + $vat / 100), 2) : $price,
'vat' => $vat,
'total_gross' => round($lineGross, 2),
'total_net' => round($lineNet, 2),
'sku' => $item['sku'] ?? '',
'ean' => $item['ean'] ?? '',
];
}
$deliveryPrice = (float) ($order['delivery_price'] ?? 0);
if ($deliveryPrice > 0) {
$deliveryVat = 23.0;
$deliveryNet = round($deliveryPrice / (1 + $deliveryVat / 100), 2);
$totalGross += $deliveryPrice;
$totalNet += $deliveryNet;
$itemsSnapshot[] = [
'name' => 'Koszt wysylki',
'quantity' => 1.0,
'price_gross' => $deliveryPrice,
'price_net' => $deliveryNet,
'vat' => $deliveryVat,
'total_gross' => round($deliveryPrice, 2),
'total_net' => round($deliveryNet, 2),
'sku' => '',
'ean' => '',
];
}
return [
'items' => $itemsSnapshot,
'total_gross' => round($totalGross, 2),
'total_net' => round($totalNet, 2),
];
}
/**
* Build Fakturownia API payload (https://app.fakturownia.pl/api).
*
* @param array<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array<string, mixed>
*/
private function buildFakturowniaPayload(
string $kind,
string $issueDate,
string $saleDate,
string $paymentDueDate,
array $sellerSnapshot,
?array $buyerSnapshot,
array $itemsSnapshot,
string $orderReference,
int $paymentToDays,
string $departmentId
): array {
$issueDay = substr($issueDate, 0, 10);
$saleDay = substr($saleDate, 0, 10);
$dueDay = substr($paymentDueDate, 0, 10);
// UWAGA: seller_* pola CELOWO pominiete. Konta Fakturowni z podwyzszonym
// poziomem zabezpieczen interpretuja roznice w seller_name/tax_no/bank
// jako proba "utworzenia nowego dzialu" i odrzucaja request HTTP 422
// ("Poziom zabezpieczenia przed zmiana konta bankowego nie pozwala na
// utworzenie dzialu"). Fakturownia uzywa wtedy danych sprzedawcy
// zarejestrowanych na koncie (uzytkownik IS sprzedawca w Fakturowni).
// Lokalny snapshot `seller_data_json` w tabeli `invoices` zachowuje
// dane orderPRO dla audytu — niezalezne od tego co poszlo do Fakturowni.
$invoice = [
'kind' => $kind !== '' ? $kind : 'vat',
'issue_date' => $issueDay,
'sell_date' => $saleDay,
'payment_to' => $dueDay,
'positions' => array_map(static function (array $item): array {
return [
'name' => (string) ($item['name'] ?? ''),
'tax' => (float) ($item['vat'] ?? 23),
'total_price_gross' => number_format((float) ($item['total_gross'] ?? 0), 2, '.', ''),
'quantity' => (float) ($item['quantity'] ?? 1),
];
}, $itemsSnapshot),
];
unset($paymentToDays, $sellerSnapshot);
if ($buyerSnapshot !== null) {
$buyerName = trim((string) ($buyerSnapshot['company_name'] ?? ''));
if ($buyerName === '') {
$buyerName = trim((string) ($buyerSnapshot['name'] ?? ''));
}
$invoice['buyer_name'] = $buyerName;
$invoice['buyer_tax_no'] = (string) ($buyerSnapshot['tax_number'] ?? '');
$invoice['buyer_street'] = (string) ($buyerSnapshot['street'] ?? '');
$invoice['buyer_post_code'] = (string) ($buyerSnapshot['postal_code'] ?? '');
$invoice['buyer_city'] = (string) ($buyerSnapshot['city'] ?? '');
$invoice['buyer_email'] = (string) ($buyerSnapshot['email'] ?? '');
}
if ($orderReference !== '') {
$invoice['additional_info_desc'] = 'Zamowienie: ' . $orderReference;
}
// department_id celowo pominiete — konta Fakturowni z podwyzszonym
// poziomem zabezpieczen odrzucaja ten parametr przez API (HTTP 422
// "Poziom zabezpieczenia ... nie pozwala na utworzenie dzialu").
// Fakturownia uzywa wtedy domyslnego dzialu konta.
unset($departmentId);
return $invoice;
}
}

View File

@@ -10,7 +10,9 @@ use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Core\Support\Flash;
use App\Core\Support\StringHelper;
use App\Modules\Accounting\InvoiceRepository;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Settings\InvoiceConfigRepository;
use App\Modules\Auth\AuthService;
use App\Modules\Email\EmailSendingService;
use App\Modules\Settings\EmailMailboxRepository;
@@ -37,7 +39,9 @@ final class OrdersController
private readonly string $storagePath = '',
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null,
private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null,
private readonly ?AutomationService $automation = null
private readonly ?AutomationService $automation = null,
private readonly ?InvoiceRepository $invoiceRepo = null,
private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null
) {
}
@@ -228,6 +232,17 @@ final class OrdersController
$emailTemplates = $this->emailTemplateRepo !== null ? $this->emailTemplateRepo->listActive() : [];
$emailMailboxes = $this->emailMailboxRepo !== null ? $this->emailMailboxRepo->listActive() : [];
$invoices = $this->invoiceRepo !== null
? $this->invoiceRepo->findByOrderId($orderId)
: [];
$activeInvoiceConfigs = [];
if ($this->invoiceConfigRepo !== null) {
$activeInvoiceConfigs = array_values(array_filter(
$this->invoiceConfigRepo->listAll(),
static fn (array $c): bool => (int) ($c['is_active'] ?? 0) === 1
));
}
$flashSuccess = (string) Flash::get('order.success', '');
$flashError = (string) Flash::get('order.error', '');
@@ -259,6 +274,8 @@ final class OrdersController
'flashError' => $flashError,
'receipts' => $receipts,
'receiptConfigs' => $activeReceiptConfigs,
'invoices' => $invoices,
'invoiceConfigs' => $activeInvoiceConfigs,
'emailTemplates' => $emailTemplates,
'emailMailboxes' => $emailMailboxes,
'customerRiskInfo' => $customerRiskInfo,
@@ -460,6 +477,34 @@ final class OrdersController
return Response::redirect('/orders/' . $orderId);
}
public function toggleInvoiceRequested(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if ($orderId <= 0) {
return Response::json(['success' => false, 'error' => 'Not found'], 404);
}
if (!Csrf::validate((string) $request->input('_token', ''))) {
return Response::json(['success' => false, 'error' => $this->translator->get('auth.errors.csrf_expired')], 403);
}
$value = (int) $request->input('invoice_requested', 0) === 1;
$this->orders->setInvoiceRequested($orderId, $value);
$user = $this->auth->user();
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
$this->orders->recordActivity(
$orderId,
'invoice_requested_changed',
'Klient prosi o fakture: ' . ($value ? 'tak' : 'nie'),
['invoice_requested' => $value ? 1 : 0],
'user',
$actorName !== '' ? $actorName : null
);
return Response::json(['success' => true, 'invoice_requested' => $value ? 1 : 0]);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>

View File

@@ -911,6 +911,14 @@ final class OrdersRepository
return true;
}
public function setInvoiceRequested(int $orderId, bool $value): void
{
$stmt = $this->pdo->prepare(
'UPDATE orders SET invoice_requested = :v, updated_at = NOW() WHERE id = :id'
);
$stmt->execute(['v' => $value ? 1 : 0, 'id' => $orderId]);
}
public function recordActivity(
int $orderId,
string $eventType,

View File

@@ -96,6 +96,13 @@ final class AllegroOrderImportService
);
}
if ($wasCreated) {
$invoiceFlag = is_array($payload['invoice'] ?? null) ? $payload['invoice'] : [];
if (!empty($invoiceFlag['required'])) {
$this->ordersRepository->setInvoiceRequested($savedOrderId, true);
}
}
if ($wasCreated && $this->automationService !== null) {
$this->automationService->trigger('order.imported', $savedOrderId, [
'source' => IntegrationSources::ALLEGRO,

View File

@@ -60,27 +60,78 @@ final class FakturowniaApiClient
}
/**
* Implementation in a follow-up plan (Phase 113-02+).
* POST /invoices.json — creates invoice in Fakturownia and returns parsed response.
*
* @param array<string, mixed> $settings
* @param array<string, mixed> $payload
* @return array<string, mixed>
* @param array{account_prefix: string, api_token: string} $settings
* @param array<string, mixed> $invoiceFields
* @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array<string, mixed>}
*/
public function createInvoice(array $settings, array $payload): array
public function createInvoice(array $settings, array $invoiceFields): array
{
unset($settings, $payload);
throw new RuntimeException('FakturowniaApiClient::createInvoice not implemented in Phase 113-01.');
$prefix = strtolower(trim((string) ($settings['account_prefix'] ?? '')));
$apiToken = trim((string) ($settings['api_token'] ?? ''));
if ($prefix === '' || $apiToken === '') {
throw new RuntimeException('Brak prefiksu konta lub tokenu API.');
}
$url = $this->buildUrl($prefix, '/invoices.json');
$body = json_encode([
'api_token' => $apiToken,
'invoice' => $invoiceFields,
], JSON_UNESCAPED_UNICODE);
if ($body === false) {
throw new RuntimeException('Nie udalo sie zakodowac payloadu (json_encode).');
}
[$rawBody, $httpCode, $curlError] = $this->httpPostJson($url, $body);
if ($curlError !== null) {
throw new RuntimeException('Blad polaczenia: ' . $curlError);
}
if ($httpCode < 200 || $httpCode >= 300) {
$msg = $this->resolveErrorMessage($rawBody);
if ($msg === '' || strtolower($msg) === 'error') {
$msg = 'raw body: ' . substr($rawBody, 0, 500);
}
error_log('[Fakturownia] createInvoice HTTP ' . $httpCode . ' | body=' . substr($rawBody, 0, 1000));
throw new RuntimeException('HTTP ' . $httpCode . ': ' . $msg);
}
$decoded = json_decode($rawBody, true);
if (!is_array($decoded)) {
throw new RuntimeException('Niepoprawna odpowiedz JSON od Fakturowni.');
}
$id = $decoded['id'] ?? null;
$number = $decoded['number'] ?? $decoded['full_number'] ?? null;
if ($id === null || $number === null) {
throw new RuntimeException('Odpowiedz Fakturowni nie zawiera id/number.');
}
$viewUrl = (string) ($decoded['view_url'] ?? '');
$pdfUrl = $this->buildPdfUrl($prefix, (string) $id, $apiToken);
return [
'id' => (string) $id,
'number' => (string) $number,
'view_url' => $viewUrl,
'pdf_url' => $pdfUrl,
'raw' => $decoded,
];
}
/**
* Implementation in a follow-up plan (Phase 113-02+).
*
* @param array<string, mixed> $settings
* Build URL for direct PDF download/redirect (no fetch — used in 302 redirect).
*/
public function downloadPdf(array $settings, string $invoiceId): string
public function buildPdfUrl(string $accountPrefix, string $invoiceId, string $apiToken): string
{
unset($settings, $invoiceId);
throw new RuntimeException('FakturowniaApiClient::downloadPdf not implemented in Phase 113-01.');
$prefix = strtolower(trim($accountPrefix));
return $this->buildUrl($prefix, '/invoices/' . rawurlencode($invoiceId) . '.pdf')
. '?api_token=' . rawurlencode($apiToken);
}
private function buildUrl(string $prefix, string $path): string
@@ -120,7 +171,48 @@ final class FakturowniaApiClient
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
}
return [(string) $rawBody, $httpCode, null];
}
/**
* @return array{0: string, 1: int, 2: ?string}
*/
private function httpPostJson(string $url, string $body): array
{
$ch = curl_init($url);
if ($ch === false) {
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
}
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'User-Agent: orderPRO/1.0',
],
];
$caPath = SslCertificateResolver::resolve();
if ($caPath !== null) {
$opts[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($ch, $opts);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
@@ -138,21 +230,61 @@ final class FakturowniaApiClient
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$candidates = ['message', 'error', 'code'];
foreach ($candidates as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
$fieldErrors = $this->flattenFieldErrors($decoded['errors'] ?? null);
if ($fieldErrors !== '') {
$code = isset($decoded['code']) && is_string($decoded['code']) ? trim($decoded['code']) : '';
return $code !== '' && strtolower($code) !== 'error'
? $code . ' — ' . $fieldErrors
: $fieldErrors;
}
foreach (['message', 'error'] as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '' && trim($decoded[$key]) !== 'error') {
return trim($decoded[$key]);
}
}
if (isset($decoded['errors']) && is_array($decoded['errors'])) {
$first = reset($decoded['errors']);
if (is_string($first) && trim($first) !== '') {
return trim($first);
}
if (isset($decoded['code']) && is_string($decoded['code']) && trim($decoded['code']) !== '') {
return trim($decoded['code']) . ' (body: ' . substr($trimmed, 0, 300) . ')';
}
}
$snippet = trim(strip_tags($trimmed));
return substr($snippet, 0, 200);
return substr($snippet, 0, 300);
}
/**
* Flatten Fakturownia per-field errors structure like:
* {"buyer_name": ["can't be blank"], "positions": ["are invalid"]}
* Into: "buyer_name: can't be blank; positions: are invalid"
*/
private function flattenFieldErrors(mixed $errors): string
{
if (!is_array($errors) || $errors === []) {
return '';
}
$parts = [];
foreach ($errors as $field => $value) {
$msgs = [];
if (is_string($value)) {
$msgs[] = trim($value);
} elseif (is_array($value)) {
foreach ($value as $v) {
if (is_string($v)) {
$msgs[] = trim($v);
} elseif (is_array($v)) {
$msgs[] = json_encode($v, JSON_UNESCAPED_UNICODE) ?: '';
}
}
}
$msgs = array_filter($msgs, static fn (string $m): bool => $m !== '');
if ($msgs === []) {
continue;
}
$parts[] = (is_string($field) ? $field . ': ' : '') . implode(', ', $msgs);
}
return implode('; ', $parts);
}
}

View File

@@ -270,6 +270,12 @@ final class ShopproOrdersSyncService
);
}
if ($savedOrderId > 0 && $wasCreated) {
if ($this->shouldRequestInvoice($rawOrder)) {
$this->orders->setInvoiceRequested($savedOrderId, true);
}
}
if ($savedOrderId > 0 && $wasCreated && !$wasPaymentTransition && $this->automationService !== null) {
$this->automationService->trigger('order.imported', $savedOrderId, [
'source' => 'shoppro',
@@ -301,6 +307,36 @@ final class ShopproOrdersSyncService
}
}
/**
* Detect "klient prosi o fakture" flag from shopPRO raw payload.
* Tries common keys; returns false when none present (manual toggle still possible).
*
* @param array<string, mixed> $rawOrder
*/
private function shouldRequestInvoice(array $rawOrder): bool
{
foreach ([['wants_invoice'], ['invoice_required'], ['invoice', 'required'], ['buyer', 'wants_invoice'], ['buyer', 'invoice']] as $path) {
$value = $rawOrder;
$found = true;
foreach ($path as $key) {
if (!is_array($value) || !array_key_exists($key, $value)) {
$found = false;
break;
}
$value = $value[$key];
}
if ($found && (
$value === true
|| $value === 1
|| $value === '1'
|| (is_string($value) && in_array(strtolower($value), ['true', 'yes', 'tak'], true))
)) {
return true;
}
}
return false;
}
/**
* @param mixed $rawIds
* @return array<int, true>