From 33ee1a1cf5488050d9902a71508098642e9a959a Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sun, 10 May 2026 23:34:50 +0200 Subject: [PATCH] feat(115): wystawianie faktury z zamowienia (lokalne + delegowane Fakturownia) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .paul/PROJECT.md | 13 +- .paul/ROADMAP.md | 12 +- .paul/STATE.md | 23 +- .paul/changelog/2026-05-10.md | 30 + .paul/codebase/architecture.md | 59 ++ .paul/codebase/tech_changelog.md | 48 ++ .paul/codebase/todo.md | 17 + .../115-invoice-from-order/115-01-PLAN.md | 359 ++++++++++ .../115-invoice-from-order/115-01-SUMMARY.md | 229 +++++++ .../js/modules/invoice-requested-toggle.js | 62 ++ resources/views/accounting/invoice_form.php | 292 +++++++++ resources/views/accounting/invoice_pdf.php | 156 +++++ .../views/accounting/invoice_preview.php | 131 ++++ .../views/accounting/invoices_issued_list.php | 144 +++++ resources/views/layouts/app.php | 1 + resources/views/orders/show.php | 71 +- resources/views/settings/accounting.php | 1 + routes/web.php | 34 +- src/Core/Http/MfWhitelistApiClient.php | 166 +++++ src/Modules/Accounting/InvoiceController.php | 292 +++++++++ .../Accounting/InvoiceIssueException.php | 10 + src/Modules/Accounting/InvoiceRepository.php | 236 +++++++ src/Modules/Accounting/InvoiceService.php | 611 ++++++++++++++++++ src/Modules/Orders/OrdersController.php | 47 +- src/Modules/Orders/OrdersRepository.php | 8 + .../Settings/AllegroOrderImportService.php | 7 + src/Modules/Settings/FakturowniaApiClient.php | 178 ++++- .../Settings/ShopproOrdersSyncService.php | 36 ++ 28 files changed, 3228 insertions(+), 45 deletions(-) create mode 100644 .paul/phases/115-invoice-from-order/115-01-PLAN.md create mode 100644 .paul/phases/115-invoice-from-order/115-01-SUMMARY.md create mode 100644 public/assets/js/modules/invoice-requested-toggle.js create mode 100644 resources/views/accounting/invoice_form.php create mode 100644 resources/views/accounting/invoice_pdf.php create mode 100644 resources/views/accounting/invoice_preview.php create mode 100644 resources/views/accounting/invoices_issued_list.php create mode 100644 src/Core/Http/MfWhitelistApiClient.php create mode 100644 src/Modules/Accounting/InvoiceController.php create mode 100644 src/Modules/Accounting/InvoiceIssueException.php create mode 100644 src/Modules/Accounting/InvoiceRepository.php create mode 100644 src/Modules/Accounting/InvoiceService.php diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index 3b83814..8fe7259 100644 --- a/.paul/PROJECT.md +++ b/.paul/PROJECT.md @@ -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 `
Deprecated...` 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* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 7ac3d9f..f695f91 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -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* diff --git a/.paul/STATE.md b/.paul/STATE.md index 576fb74..ddb7a30 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -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 diff --git a/.paul/changelog/2026-05-10.md b/.paul/changelog/2026-05-10.md index 8de98c3..cc586b0 100644 --- a/.paul/changelog/2026-05-10.md +++ b/.paul/changelog/2026-05-10.md @@ -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` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index 088614e..c968f32 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -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 diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md index a8b9931..521d576 100644 --- a/.paul/codebase/tech_changelog.md +++ b/.paul/codebase/tech_changelog.md @@ -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:** diff --git a/.paul/codebase/todo.md b/.paul/codebase/todo.md index 32d3867..f14c3cf 100644 --- a/.paul/codebase/todo.md +++ b/.paul/codebase/todo.md @@ -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=` 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 diff --git a/.paul/phases/115-invoice-from-order/115-01-PLAN.md b/.paul/phases/115-invoice-from-order/115-01-PLAN.md new file mode 100644 index 0000000..f05961e --- /dev/null +++ b/.paul/phases/115-invoice-from-order/115-01-PLAN.md @@ -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 +--- + + +## 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. + + + + +- **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). + + +## 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 + + + + +## 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) +``` + + + + + + + Task 1: Backend - InvoiceService + InvoiceRepository + Fakturownia API client + + 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 + + + 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). + + + `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). + + AC-2 (lokalny insert), AC-3 (delegated POST najpierw, INSERT po sukcesie) - infrastruktura backend gotowa. + + + + Task 2: UI zamowienia - toggle invoice_requested + form wystawiania + endpointy + auto-import flagi + + 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 + + + 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 `` -> `Wystaw fakture`. + - 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`. + + + `php -l` na zmienionych plikach. + Smoke (XAMPP online): toggle invoice_requested -> reload -> przycisk widoczny -> klik -> form -> submit lokalny -> preview otwiera sie. + + AC-1 (toggle + auto-import), AC-2 (lokalny flow do preview), AC-3 (delegated form path). + + + + Task 3: Lista wystawionych faktur w sekcji Ksiegowosc + podglad/PDF + + 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 + + + 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` -> `PDF (Fakturownia)`; dla lokalnych -> `PDF`. + 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. + + + `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. + + AC-4 (lista + podglad + PDF dual-path). + + + + Task 3b: GUS lookup — pobieranie danych nabywcy z NIP przez Fakturownie + + src/Modules/Settings/FakturowniaApiClient.php, + src/Modules/Accounting/InvoiceController.php, + resources/views/accounting/invoice_form.php, + routes/web.php + + + 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 ` + + + + diff --git a/resources/views/accounting/invoice_pdf.php b/resources/views/accounting/invoice_pdf.php new file mode 100644 index 0000000..a03d7ac --- /dev/null +++ b/resources/views/accounting/invoice_pdf.php @@ -0,0 +1,156 @@ + + + + + Faktura <?= $e((string) ($invoice['invoice_number'] ?? '')) ?> + + + + +
+
+
+ + +
NIP:
+ +
+
+ +
Tel:
+ + +
Email:
+ +
+
+

FAKTURA VAT

+
+ +
Data wystawienia:
+ + +
Data sprzedazy:
+ +
+
+ + +
+
+ Sprzedawca +
+ +
NIP:
+ +
+
+
+
+ Nabywca + +
+ + +
+ + +
NIP:
+ +
+
+
+
+ + + + + + + + + + + + + + + + $item): ?> + + + + + + + + + + + + + + + + + + + + + + + + + +
Lp.NazwaIloscCena nettoVATCena bruttoSuma brutto
%
Razem netto
Razem VAT
Razem brutto PLN
+ + +
+ Termin platnosci: + + | Konto: + +
+ + +
+ +
Nr referencyjny:
+ +
Typ dokumentu:
+
+
+ + diff --git a/resources/views/accounting/invoice_preview.php b/resources/views/accounting/invoice_preview.php new file mode 100644 index 0000000..d4e9d32 --- /dev/null +++ b/resources/views/accounting/invoice_preview.php @@ -0,0 +1,131 @@ + + +
+
+
+ ← Powrot do zamowienia +

Faktura

+
+ + Wystawione w Fakturowni: + (id zewnetrzne: ) + + Wystawione lokalnie + +
+
+ +
+ +
+
+

Sprzedawca

+
+
Firma
+
NIP
+
Adres
,
+
Telefon
+
Email
+ +
Konto
+ +
+
+ +
+

Nabywca

+
+ +
Firma
+ + +
Nazwa
+ + +
NIP
+ +
Adres
,
+ +
Email
+ +
+
+ +
+ +

Pozycje

+
+ + + + + + + + + + + + + + $item): ?> + + + + + + + + + + + + + + + + + + + + + +
Lp.NazwaIloscCena nettoVATCena bruttoSuma brutto
%
Razem netto
Razem brutto PLN
+
+ +
+
+ +
Data wystawienia
= 16 ? substr($issueDateShow, 0, 16) : $issueDateShow) ?>
+
Data sprzedazy
+ +
Termin platnosci
+ +
Konfiguracja
+
Typ
+ +
Nr referencyjny
+ +
+
+
diff --git a/resources/views/accounting/invoices_issued_list.php b/resources/views/accounting/invoices_issued_list.php new file mode 100644 index 0000000..099ba01 --- /dev/null +++ b/resources/views/accounting/invoices_issued_list.php @@ -0,0 +1,144 @@ + + +
+
+
+ ← Ksiegowosc +

Wystawione faktury

+
Lacznie:
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Wyczysc +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NumerData wystawieniaNabywcaBruttoTrybKonfiguracjaZamowienieAkcje
Brak faktur w wybranych filtrach
= 16 ? substr($issueDate, 0, 16) : $issueDate) ?> + + +
NIP:
+ +
PLN + + Fakturownia: + + Lokalnie + + + Podglad + + PDF + + PDF + +
+
+ + 1): ?> + + +
diff --git a/resources/views/layouts/app.php b/resources/views/layouts/app.php index 4730e10..49f6215 100644 --- a/resources/views/layouts/app.php +++ b/resources/views/layouts/app.php @@ -192,6 +192,7 @@ + diff --git a/resources/views/orders/show.php b/resources/views/orders/show.php index ebeae6c..75b0919 100644 --- a/resources/views/orders/show.php +++ b/resources/views/orders/show.php @@ -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) { Wystaw paragon + + + Wystaw fakture + + + +

+
+ + Po zaznaczeniu pojawi sie przycisk "Wystaw fakture" w naglowku zamowienia. +
+ 'Nieopłacone', 1 => 'Częściowo opłacone', 2 => 'Opłacone', 3 => 'Zwrócone']; @@ -843,9 +865,56 @@ foreach ($addressesList as $address) {

- +

+ +

Faktury

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
NumerData wystawieniaBruttoTrybKonfiguracjaAkcje
= 16 ? substr($invIssueDate, 0, 16) : $invIssueDate) ?> + + Fakturownia: + + Lokalnie + + + Podglad + + PDF + + PDF + +
+
+

Paragony

diff --git a/resources/views/settings/accounting.php b/resources/views/settings/accounting.php index 8375e0a..758881f 100644 --- a/resources/views/settings/accounting.php +++ b/resources/views/settings/accounting.php @@ -29,6 +29,7 @@ $error = trim((string) ($errorMessage ?? ''));

Konfiguracje wystawiania faktur: numeracja lokalna lub delegacja do Fakturowni, termin platnosci, typ dokumentu.

diff --git a/routes/web.php b/routes/web.php index 8273a26..cd71167 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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]); diff --git a/src/Core/Http/MfWhitelistApiClient.php b/src/Core/Http/MfWhitelistApiClient.php new file mode 100644 index 0000000..96c58d8 --- /dev/null +++ b/src/Core/Http/MfWhitelistApiClient.php @@ -0,0 +1,166 @@ +} + */ + 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); + } +} diff --git a/src/Modules/Accounting/InvoiceController.php b/src/Modules/Accounting/InvoiceController.php new file mode 100644 index 0000000..4134d35 --- /dev/null +++ b/src/Modules/Accounting/InvoiceController.php @@ -0,0 +1,292 @@ +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 $invoice + * @return array + */ + 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'] ?? '')), + ]; + } +} diff --git a/src/Modules/Accounting/InvoiceIssueException.php b/src/Modules/Accounting/InvoiceIssueException.php new file mode 100644 index 0000000..0338b9e --- /dev/null +++ b/src/Modules/Accounting/InvoiceIssueException.php @@ -0,0 +1,10 @@ +> + */ + 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|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 $data + */ + public function insertLocal(array $data): int + { + return $this->insert($data, isDelegated: false); + } + + /** + * @param array $data + */ + public function insertDelegated(array $data): int + { + return $this->insert($data, isDelegated: true); + } + + /** + * @param array $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 $filters + * @return array{items: list>, 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, + ]; + } +} diff --git a/src/Modules/Accounting/InvoiceService.php b/src/Modules/Accounting/InvoiceService.php new file mode 100644 index 0000000..5be1451 --- /dev/null +++ b/src/Modules/Accounting/InvoiceService.php @@ -0,0 +1,611 @@ +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 $config + * @param array $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $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 $config + * @param array $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $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 $config + * @param array $order + * @param list> $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 $config + * @param array $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 + */ + 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 $order + * @param list> $addresses + * @param array $params + * @return array|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 $order + * @param array|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> + */ + 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 $arr + * @param list $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> $items + * @param array $order + * @return array{items: list>, 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 $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $itemsSnapshot + * @return array + */ + 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; + } +} diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php index 3d10f0a..c9c5f77 100644 --- a/src/Modules/Orders/OrdersController.php +++ b/src/Modules/Orders/OrdersController.php @@ -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 $row * @return array diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php index 5d125b8..b35f9a6 100644 --- a/src/Modules/Orders/OrdersRepository.php +++ b/src/Modules/Orders/OrdersRepository.php @@ -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, diff --git a/src/Modules/Settings/AllegroOrderImportService.php b/src/Modules/Settings/AllegroOrderImportService.php index 3ff4fa1..5419d28 100644 --- a/src/Modules/Settings/AllegroOrderImportService.php +++ b/src/Modules/Settings/AllegroOrderImportService.php @@ -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, diff --git a/src/Modules/Settings/FakturowniaApiClient.php b/src/Modules/Settings/FakturowniaApiClient.php index 75f1a69..7521747 100644 --- a/src/Modules/Settings/FakturowniaApiClient.php +++ b/src/Modules/Settings/FakturowniaApiClient.php @@ -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 $settings - * @param array $payload - * @return array + * @param array{account_prefix: string, api_token: string} $settings + * @param array $invoiceFields + * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array} */ - 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 $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); } } diff --git a/src/Modules/Settings/ShopproOrdersSyncService.php b/src/Modules/Settings/ShopproOrdersSyncService.php index dd69543..edd1b97 100644 --- a/src/Modules/Settings/ShopproOrdersSyncService.php +++ b/src/Modules/Settings/ShopproOrdersSyncService.php @@ -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 $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