diff --git a/.paul/STATE.md b/.paul/STATE.md
index a571f87..d8a5347 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -4,6 +4,18 @@
**Ostatnia aktualizacja:** 2026-05-20
## Aktywna praca
+UNIFY zakonczony dla `.paul/plans/20260520-1500-refactor-invoice-service/`. Petla zamknieta. `InvoiceService.php` 762 -> 118 lin. (84% redukcji). Wydzielono 5 nowych klas w `src/Modules/Accounting/`: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223), `FakturowniaInvoicePayloadBuilder` (84), `LocalInvoiceIssuer` (66, deviation — symetria do delegated), `DelegatedInvoiceIssuer` (253). Zero zmian publicznego kontraktu (konstruktor 6-arg, `issue(array)`, statyk `extractBuyerTaxNumber` jako forwarder). Konsumenci niezmienieni: `InvoiceController`, `AccountingModule`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`. `php -l` zielony 7/7. Deviation: incydentalna naprawa pre-existing bugu — `LocalInvoiceIssuer::issue` ma poprawne 14 paramow vs oryginalny `issueLocal` 15 paramow w sygnaturze (sciezka local rzucala `ArgumentCountError`). SUMMARY: `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`.
+
+```
+PLAN ──▶ APPLY ──▶ UNIFY
+ ✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN]
+```
+
+⚠ **Do weryfikacji recznej (vendor/phpunit niedostepny w sesji):**
+1. `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 3/3 scenariusze idempotencji musza przejsc bez modyfikacji testu (primary gate).
+2. Smoke `POST /orders/{id}/invoice/create` dla konfiguracji delegowanej (Fakturownia) i lokalnej (sciezka local teraz wlasciwie dziala po naprawie sygnatury).
+
+## Poprzednia praca
UNIFY zakonczony dla `.paul/plans/20260520-1128-storage-cleanup-cron/`. Petla zamknieta. Dodano cron handler `storage_cleanup` (`src/Modules/Cron/StorageCleanupHandler.php`, ~140 lin.) usuwajacy pliki >30 dni z `storage/{labels,sessions,tmp,logs,cache}`. `storage/data/` i `storage/logs/app.log` chronione. Migracja seed `20260520_000119_seed_storage_cleanup_cron.sql` (interval 86400s, payload `{"days":30}`, priority 80). Konfiguracja: `STORAGE_CLEANUP_DAYS=30` w `.env.example` (fallback). Offline smoke test 7/7 OK; reczny UAT DB-side wymagany. SUMMARY: `.paul/plans/20260520-1128-storage-cleanup-cron/SUMMARY.md`.
```
diff --git a/.paul/changelog/2026-05-20.md b/.paul/changelog/2026-05-20.md
index 854ca30..19f7a93 100644
--- a/.paul/changelog/2026-05-20.md
+++ b/.paul/changelog/2026-05-20.md
@@ -4,6 +4,10 @@
- [Plan 20260520-1128-storage-cleanup-cron] Dodano cron handler `storage_cleanup` usuwajacy pliki >30 dni z `storage/{labels,sessions,tmp,logs,cache}`. `storage/data/` i `storage/logs/app.log` chronione. Migracja seed `cron_schedules` (interval 86400s, payload `{"days":30}`). Konfiguracja przez `STORAGE_CLEANUP_DAYS` env (fallback). Log wykonania do `storage/logs/app.log`.
- Offline smoke test 7/7 OK (handler logic). Reczny UAT DB-side wymagany (DB niedostepna w sesji).
+- [Plan 20260520-1500-refactor-invoice-service] Dekompozycja `src/Modules/Accounting/InvoiceService.php` z 762 do 118 lin. (84% redukcji). Wydzielono 5 nowych klas w `src/Modules/Accounting/`: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223, ze statyka `extractBuyerTaxNumber`), `FakturowniaInvoicePayloadBuilder` (84, z komentarzami HTTP 422), `LocalInvoiceIssuer` (66), `DelegatedInvoiceIssuer` (253, idempotentna sciezka Fakturowni).
+- Zero zmian publicznego kontraktu: konstruktor 6-arg, `issue(array): array`, statyk `InvoiceService::extractBuyerTaxNumber()` jako forwarder. `InvoiceController`, `AccountingModule`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` niezmienione.
+- Incydentalna naprawa pre-existing bugu: oryginalny `issueLocal()` mial 15 paramow w sygnaturze vs 14 przekazywanych — sciezka local rzucala `ArgumentCountError`. `LocalInvoiceIssuer::issue` ma poprawne 14 paramow.
+- `php -l` zielony dla 7 plikow. Reczny UAT (vendor/phpunit niedostepny): `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` + smoke `POST /orders/{id}/invoice/create`.
## Zmienione pliki
@@ -12,9 +16,19 @@
- `database/migrations/20260520_000119_seed_storage_cleanup_cron.sql`
- `.env.example`
- `CLAUDE.md`
+- `src/Modules/Accounting/InvoiceService.php`
+- `src/Modules/Accounting/InvoiceMetadataResolver.php`
+- `src/Modules/Accounting/InvoiceSnapshotBuilder.php`
+- `src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php`
+- `src/Modules/Accounting/LocalInvoiceIssuer.php`
+- `src/Modules/Accounting/DelegatedInvoiceIssuer.php`
- `.paul/codebase/architecture.md`
- `.paul/codebase/db_schema.md`
+- `.paul/codebase/quality_risks.md`
- `.paul/codebase/tech_changelog.md`
+- `.paul/codebase/tooling_status.md`
- `.paul/plans/20260520-1128-storage-cleanup-cron/PLAN.md`
- `.paul/plans/20260520-1128-storage-cleanup-cron/SUMMARY.md`
+- `.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md`
+- `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`
- `.paul/STATE.md`
diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md
index 5331f2e..30a141a 100644
--- a/.paul/codebase/architecture.md
+++ b/.paul/codebase/architecture.md
@@ -66,7 +66,7 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.):
| Orders | `src/Modules/Orders/` | zamowienia, notatki, import, statusy |
| Statistics | `src/Modules/Statistics/` | raporty zamowien (slim Controller + `OrdersStatisticsFilters` + `OrdersStatisticsTableBuilder` + `OrdersStatisticsSummaryBuilder` + Repository) |
| Settings/Allegro | `src/Modules/Settings/Allegro*.php` | integracja Allegro: slim `AllegroIntegrationController` + `AllegroIntegrationViewModel` + `AllegroOAuthFlowService` + `AllegroImportScheduleService` + `AllegroImportImageWarningFormatter` + `AllegroSaveSettingsValidator` + `AllegroOrderImportService` + `AllegroStatusDiscoveryService` + `AllegroStatusMappingController` + `AllegroDeliveryMappingController` + repozytoria (`AllegroIntegrationRepository`, `AllegroStatusMappingRepository`, `AllegroPullStatusMappingRepository`) + klienci (`AllegroApiClient`, `AllegroOAuthClient`, `AllegroTokenManager`). |
-| Accounting | `src/Modules/Accounting/` | paragony, faktury, eksport ksiegowy |
+| Accounting | `src/Modules/Accounting/` | paragony, faktury (slim fasada `InvoiceService` + `InvoiceMetadataResolver` resolvery dat/oid + `InvoiceSnapshotBuilder` seller/buyer/items + statyka `extractBuyerTaxNumber` + `FakturowniaInvoicePayloadBuilder` payload API + `LocalInvoiceIssuer` sciezka lokalna + `DelegatedInvoiceIssuer` idempotentna sciezka Fakturowni), eksport ksiegowy |
| Shipments | `src/Modules/Shipments/` | etykiety, tracking, presets paczek; slownik statusow dostawy jako fasada `DeliveryStatus` + `DeliveryStatusProviderMap` (mapy dostawcow) + `AllegroDescriptionGuesser` (heurystyki opisow) + `DeliveryTrackingUrlBuilder` (URL sledzenia) |
| Printing | `src/Modules/Printing/` | API drukowania etykiet (klient Windows) |
| Email | `src/Modules/Email/` | SMTP, wysylka maili |
diff --git a/.paul/codebase/quality_risks.md b/.paul/codebase/quality_risks.md
index 95a4405..9542e28 100644
--- a/.paul/codebase/quality_risks.md
+++ b/.paul/codebase/quality_risks.md
@@ -19,7 +19,7 @@ Konwencja (`CLAUDE.md`): funkcja/klasa zwykle do 30-50 linii, max 3 poziomy zagn
| `src/Modules/Settings/AllegroOrderImportService.php` | 834 | duzy import service. |
| `src/Modules/Automation/AutomationService.php` | 818 | silnik regul — kandydat na pipeline-strategy. |
| `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | |
-| `src/Modules/Accounting/InvoiceService.php` | 762 | |
+| ~~`src/Modules/Accounting/InvoiceService.php`~~ | ~~762~~ -> 118 | ✅ Zrefaktorowane 2026-05-20 — fasada (118) + 5 wspolpracownikow: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223), `FakturowniaInvoicePayloadBuilder` (84), `LocalInvoiceIssuer` (66), `DelegatedInvoiceIssuer` (253). Zero zmian kontraktu (konstruktor 6-arg, `issue()`, statyk `extractBuyerTaxNumber`). Patrz `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`. |
| ~~`src/Modules/Automation/AutomationController.php`~~ | ~~677~~ -> 221 | ✅ Zrefaktorowane 2026-05-20 — wydzielono `AutomationRequestParser` (422), `AutomationFormViewModel` (80), `AutomationHistoryFilters` (58); szablon `destroy/duplicate/toggleStatus` zlozony do `runIdAction()`. Zero zmian kontraktu HTTP/widokow/DB. Patrz `.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md`. |
| ~~`src/Modules/Shipments/DeliveryStatus.php`~~ | ~~657~~ -> 170 | ✅ Zrefaktorowane 2026-05-19 — fasada zachowujaca pelny kontrakt + wydzielono `DeliveryStatusProviderMap` (~330), `AllegroDescriptionGuesser` (~150), `DeliveryTrackingUrlBuilder` (~85). Zero zmian w 20 plikach konsumentow. Patrz `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`. |
| ~~`src/Modules/Settings/AllegroIntegrationController.php`~~ | ~~653~~ -> 223 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Patrz `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`. |
diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md
index b1941cc..f29b148 100644
--- a/.paul/codebase/tech_changelog.md
+++ b/.paul/codebase/tech_changelog.md
@@ -2,6 +2,42 @@
Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze.
+## 2026-05-20 — Dekompozycja `InvoiceService` (762 -> 118 lin., fasada + 5 wspolpracownikow)
+
+### Co
+- `src/Modules/Accounting/InvoiceService.php` zredukowany z 762 do 118 lin. (84% redukcji) — slim fasada zachowujaca pelny publiczny kontrakt.
+- Wydzielono 5 nowych klas w `src/Modules/Accounting/`:
+ - `InvoiceMetadataResolver` (84) — resolvery dat (`resolveIssueDate`, `resolveSaleDate`, `resolvePaymentDueDate`) + `resolveOrderReference` + `resolveFakturowniaOid`.
+ - `InvoiceSnapshotBuilder` (223) — `buildSellerSnapshot`, `buildBuyerSnapshot`, `buildItemsSnapshot` + publiczna statyka `extractBuyerTaxNumber` (+ prywatne helpery `taxNumberPaths`, `digValue`).
+ - `FakturowniaInvoicePayloadBuilder` (84) — `build()` z zachowanymi komentarzami "UWAGA: seller_* CELOWO pominiete" i "department_id celowo pominiete" (HTTP 422 ograniczenia API Fakturowni).
+ - `LocalInvoiceIssuer` (66) — lokalna sciezka wystawienia (insertLocal + nextLocalNumber).
+ - `DelegatedInvoiceIssuer` (253) — idempotentna sciezka Fakturowni: `findByConfigAndExternalOid -> isIssuedDelegatedInvoice -> findRemoteInvoiceByOid -> insertDelegatedPending -> createInvoice -> finalizeDelegatedExternal`.
+- `InvoiceService::extractBuyerTaxNumber()` pozostaje jako publiczny statyczny forwarder do `InvoiceSnapshotBuilder::extractBuyerTaxNumber()` — `InvoiceController.php:97` dzialalo bez zmian.
+
+### Dlaczego
+- `InvoiceService` byl monolitem (`quality_risks.md`: 762 lin., kandydat do podzialu) lacacym 6 odpowiedzialnosci: resolvery, buildery, payload API, sciezka local, idempotentna sciezka delegated. Naruszal regule `CLAUDE.md` (klasa zwykle do 30-50 lin.).
+- Wzorzec sprawdzony w projekcie (DeliveryStatus 657->170, AutomationController 677->221, AllegroIntegrationController 653->223): slim fasada + wspolpracownicy.
+
+### Zachowane gwarancje
+- Konstruktor `InvoiceService(6 zaleznosci)` bez zmian — `AccountingModule` (wpis `accounting.invoices.service`) i `tests/Unit/FakturowniaInvoiceIdempotencyTest.php::createService()` dzialaja bez modyfikacji.
+- Sygnatura `issue(array $params): array` i ksztalt zwracanej tablicy (`invoice_id, invoice_number, total_gross, mode`) bez zmian.
+- Statyk `InvoiceService::extractBuyerTaxNumber()` zachowany jako forwarder.
+- Payload Fakturowni 1:1 (celowy brak `seller_*` i `department_id` udokumentowany w nowej klasie).
+- Test `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — zero modyfikacji (primary gate, wymaga `vendor/phpunit` do uruchomienia).
+
+### Obserwacja (pre-existing bug, NIE adresowane w tym planie)
+- Oryginalny `issueLocal()` mial w sygnaturze 15 parametrow (w tym nieuzywany `$externalOid`), a wywolanie z `issue()` przekazywalo 14 argumentow. Sciezka local (config `is_delegated=0`) wywolalaby `ArgumentCountError` przy pierwszej probie. Ze wzgledu na zero zglosen tego defekt nie byl zauwazony.
+- Podczas refaktoru `LocalInvoiceIssuer::issue()` ma poprawne 14 parametrow (brak vestigialnego `$externalOid`) i jest wywolywane spojnie. To incydentalna naprawa — sciezka local przestaje rzucac ArgumentCountError, ale w testach nie ma jej pokrycia, wiec wlasciwy regression test pozostaje do dolozenia w osobnym planie.
+
+### Weryfikacja w sesji
+- `php -l` zielone dla 7 plikow (5 nowych klas + zmieniona fasada + 2 konsumenci `InvoiceController`, `AccountingModule`).
+- Reflection: `InvoiceMetadataResolver` ma 5 publicznych metod, `FakturowniaInvoicePayloadBuilder::build` istnieje.
+- `grep` potwierdza zachowanie wolan `InvoiceService::extractBuyerTaxNumber(...)` i `new InvoiceService(...)` w nieztkniete plikach.
+- `vendor/phpunit` niedostepny -> uruchomienie testu idempotencji do recznej weryfikacji.
+
+### Plan
+- `.paul/plans/20260520-1500-refactor-invoice-service/`
+
## 2026-05-20 — Cron `storage_cleanup` (retencja 30 dni dla katalogu `storage/`)
### Co
diff --git a/.paul/codebase/tooling_status.md b/.paul/codebase/tooling_status.md
index 694c480..2fe2faa 100644
--- a/.paul/codebase/tooling_status.md
+++ b/.paul/codebase/tooling_status.md
@@ -1,7 +1,20 @@
# Tooling Status
-**Timestamp:** 2026-05-19
-**Tryb skanu:** full (`/paul:map-codebase`)
+**Timestamp:** 2026-05-20 (last post-apply: refactor InvoiceService)
+**Tryb skanu:** full (`/paul:map-codebase` 2026-05-19) + post-apply (2026-05-20)
+
+## Post-apply 2026-05-20 — refactor `InvoiceService`
+
+- Plan: `.paul/plans/20260520-1500-refactor-invoice-service/`
+- `php -l` (XAMPP `C:\xampp\php\php.exe`) na 7 plikach modulu `Accounting/` -> No syntax errors.
+- Reflection probe na `InvoiceMetadataResolver` (5 publicznych metod) i `FakturowniaInvoicePayloadBuilder::build` -> OK.
+- `vendor/phpunit` NIEDOSTEPNY -> uruchomienie `FakturowniaInvoiceIdempotencyTest` przelozone na reczna weryfikacje.
+- codebase-memory-mcp: re-index nie uruchomiony (zmiany w 1 module — odswiezenie grafu przy nastepnym `$paul-map-codebase`).
+- jscpd / ast-grep: nadal disabled by policy.
+
+---
+
+
## Status narzedzi
diff --git a/.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md b/.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md
new file mode 100644
index 0000000..9982b3f
--- /dev/null
+++ b/.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md
@@ -0,0 +1,331 @@
+---
+plan_id: 20260520-1500-refactor-invoice-service
+title: Dekompozycja InvoiceService (762 lin.) na fasade + 4 wspolpracownikow
+storage: plan-first
+legacy_phase: null
+created: 2026-05-20T15:00:00+02:00
+status: planned
+type: execute
+autonomous: true
+delegation: auto
+files_modified:
+ - src/Modules/Accounting/InvoiceService.php
+ - src/Modules/Accounting/InvoiceMetadataResolver.php
+ - src/Modules/Accounting/InvoiceSnapshotBuilder.php
+ - src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php
+ - src/Modules/Accounting/DelegatedInvoiceIssuer.php
+ - src/Modules/Accounting/AccountingModule.php
+ - .paul/codebase/architecture.md
+ - .paul/codebase/quality_risks.md
+ - .paul/codebase/tech_changelog.md
+quality_radar: ok
+---
+
+
+## Cel
+Zredukowac `src/Modules/Accounting/InvoiceService.php` z 762 lin. do ~150 lin. (fasada) przez wydzielenie spojnych odpowiedzialnosci do 4 nowych klas w `src/Modules/Accounting/`.
+
+## Po co
+Plik to dzis monolit lacacy 6 niezaleznych odpowiedzialnosci: resolvery (data wystawienia/sprzedazy/termin platnosci/referencja zamowienia/oid), buildery snapshotow (seller/buyer/items + ekstrakcja NIP), builder payloadu Fakturowni, sciezka "local" oraz idempotentna sciezka "delegated" do Fakturowni. Klasa ma 23 metody (1 publiczna `issue`, 1 publiczna statyczna `extractBuyerTaxNumber`, 21 prywatnych), wykracza poza limit z `CLAUDE.md` (klasa zwykle do 30-50 lin., metoda taksamo). Wzorzec sprawdzony juz w projekcie: `DeliveryStatus` (657 -> 170, fasada + 3 klasy), `AutomationController` (677 -> 221, slim + 3 wspolpracownicy), `AllegroIntegrationController` (653 -> 223).
+
+## Wynik
+- `InvoiceService` jako slim fasada (~140-160 lin.): konstruktor + `issue()` + statyczne `extractBuyerTaxNumber()` jako forwarder.
+- 4 nowe klasy:
+ - `InvoiceMetadataResolver` (~110 lin.) — `resolveIssueDate`, `resolveSaleDate`, `resolvePaymentDueDate`, `resolveOrderReference`, `resolveFakturowniaOid`.
+ - `InvoiceSnapshotBuilder` (~230 lin.) — `buildSellerSnapshot`, `buildBuyerSnapshot`, `buildItemsSnapshot`, publiczna statyczna `extractBuyerTaxNumber` + prywatne `taxNumberPaths`, `digValue`.
+ - `FakturowniaInvoicePayloadBuilder` (~80 lin.) — `build()` (zachowuje komentarze "UWAGA seller_*/department_id celowo pominiete").
+ - `DelegatedInvoiceIssuer` (~190 lin.) — pelna sciezka delegowana (idempotencja, lookup remote, attach, finalize) + `buildDelegatedInvoiceData`.
+- Zero zmian w kontrakcie publicznym `InvoiceService` (konstruktor, sygnatura `issue()`, sygnatura `extractBuyerTaxNumber()`).
+- Zero zmian w `InvoiceController`, `AccountingModule` (konstruktor), tabeli `invoices`, payloadach Fakturowni i wynikach `issue()`.
+- `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` przechodzi bez modyfikacji (primary gate idempotencji).
+
+
+
+## Project Docs
+@.paul/PROJECT.md
+@.paul/STATE.md
+@.paul/codebase/architecture.md
+@.paul/codebase/db_schema.md
+@.paul/codebase/impact_map.md
+@.paul/codebase/quality_risks.md
+
+## Source Files
+@src/Modules/Accounting/InvoiceService.php
+@src/Modules/Accounting/InvoiceController.php
+@src/Modules/Accounting/InvoiceRepository.php
+@src/Modules/Accounting/InvoiceIssueException.php
+@src/Modules/Accounting/AccountingModule.php
+@src/Modules/Settings/FakturowniaApiClient.php
+@src/Modules/Settings/FakturowniaIntegrationRepository.php
+@tests/Unit/FakturowniaInvoiceIdempotencyTest.php
+
+
+
+- Zachowujemy `InvoiceService::extractBuyerTaxNumber()` jako publiczna statyczna fasade -> deleguje do `InvoiceSnapshotBuilder::extractBuyerTaxNumber()`. Powod: `InvoiceController.php:97` wola `InvoiceService::extractBuyerTaxNumber($order, $buyerAddress)`, zmiana wymagalaby edycji kontrolera; trzymanie forwardera daje refaktor "zero zmian zewnetrznych".
+- Sygnatura konstruktora `InvoiceService` pozostaje bez zmian — `AccountingModule` (wpis `accounting.invoices.service`) i `tests/Unit/FakturowniaInvoiceIdempotencyTest.php::createService()` montuja 6 zaleznosci w obecnej kolejnosci.
+- 4 nowe klasy buduje fasada w konstruktorze (poor man's DI, spojnie z reszta projektu); nie dodajemy ich do `ServiceRegistry` — sa wewnetrznymi szczegolami `InvoiceService`.
+
+
+
+## Quality Radar
+
+**Status:** ok
+**Tryb:** plan (impact scan przez codebase-memory-mcp na podstawie `.paul/codebase/quality_risks.md` z 2026-05-19)
+**Tools:** codebase-memory-mcp (search_graph + Grep weryfikacja konsumentow); jscpd/ast-grep wylaczone polityka (`.paul/config.md`).
+
+## Obszary dotkniete
+
+- `src/Modules/Accounting/` — wszystkie zmiany lokalne dla modulu Accounting.
+- `src/Modules/Accounting/InvoiceController.php` — TYLKO odczyt (uzywa `new InvoiceService(...)` posrednio przez DI oraz statyki `InvoiceService::extractBuyerTaxNumber`). Bez modyfikacji.
+- `src/Modules/Accounting/AccountingModule.php` — bez zmian sygnatury, tylko ewentualne `use`-y nowych klas nie sa potrzebne (fasada buduje wspolpracownikow sama).
+- `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — bez modyfikacji (primary gate).
+- `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md` — aktualizacja dokumentacji.
+
+## Duplicate / Hardcoded Risks
+
+- `quality_risks.md` wymienia `InvoiceService.php` (762) jako kandydata do podzialu — adresowane w Task 1-5.
+- Powtarzajacy sie wzorzec `(string)($x['key'] ?? '')` i `trim(...)` w `buildBuyerSnapshot` — celowo nie ujednolicamy w tym planie (zaden test ani wymaganie tego nie wymusza; ryzyko regresji > zysk). Deferred.
+- `buildSellerSnapshot` i `buildBuyerSnapshot` produkuja podobne tablice kluczy (`company_name`/`street`/`city`/...) — to dwa rozne kontrakty (seller pelny, buyer ze snapshotem nullowalnym). Nie laczymy.
+
+## Explicit Deferrals
+
+- Pelne pokrycie testami `InvoiceService::issue()` sciezki "local" — out of scope; istniejacy `FakturowniaInvoiceIdempotencyTest` pokrywa sciezke delegated (3 scenariusze idempotencji) i pozostaje primary gate.
+- Refaktor `InvoiceController.php` (728 lin.) i `InvoiceRepository.php` — osobne plany.
+- Eliminacja statycznej fasady `InvoiceService::extractBuyerTaxNumber` (migracja wolan w `InvoiceController`) — osobny mikroplan jezeli zechcemy posprzatac, wstecznie nieblokujace.
+
+
+
+Brak SPECIAL-FLOWS.md w `.paul/` — sekcja pominieta.
+
+
+
+
+## AC-1: Fasada `InvoiceService` zachowuje publiczny kontrakt
+```gherkin
+Given istniejacy `InvoiceService` z konstruktorem 6 zaleznosci, publiczna `issue(array $params): array` i statyczna `public static extractBuyerTaxNumber(array $order, ?array $buyerAddress): string`
+When zakonczy sie refaktor
+Then `InvoiceController.php:97` (`InvoiceService::extractBuyerTaxNumber(...)`) i `AccountingModule.php:66-73` (`new InvoiceService(repo, configs, companies, orders, fakturownia, fakturowniaApi)`) dzialaja bez zmian
+And `tests/Unit/FakturowniaInvoiceIdempotencyTest.php::createService()` zwraca dzialajaca instancje
+And `php -l src/Modules/Accounting/InvoiceService.php` zwraca `No syntax errors`
+```
+
+## AC-2: 4 nowe klasy wyodrebnione w `src/Modules/Accounting/`
+```gherkin
+Given dekompozycja na fasade + 4 wspolpracownikow
+When sprawdzimy katalog `src/Modules/Accounting/`
+Then istnieja pliki:
+ - `InvoiceMetadataResolver.php` (~110 lin., 5 metod resolver*)
+ - `InvoiceSnapshotBuilder.php` (~230 lin., 3 buildery + statyka `extractBuyerTaxNumber` + 2 helpery prywatne)
+ - `FakturowniaInvoicePayloadBuilder.php` (~80 lin., 1 metoda `build()`)
+ - `DelegatedInvoiceIssuer.php` (~190 lin., orchestracja sciezki delegated)
+And kazdy plik `declare(strict_types=1)`, `final class`, `namespace App\Modules\Accounting`
+And `php -l` dla kazdego pliku zwraca `No syntax errors`
+```
+
+## AC-3: `InvoiceService` zredukowany do <=160 lin.
+```gherkin
+Given pierwotne 762 lin.
+When zakonczy sie refaktor
+Then `wc -l src/Modules/Accounting/InvoiceService.php` zwraca <= 160
+And klasa zawiera tylko: konstruktor, `issue()`, statyczna `extractBuyerTaxNumber()` jako forwarder
+```
+
+## AC-4: Test idempotencji Fakturowni przechodzi bez zmian
+```gherkin
+Given `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` (3 scenariusze: pending insert + remote create, lookup remote przed call, retry po Throwable z findRemote)
+When uruchomimy test (gdy vendor/phpunit dostepny) lub zweryfikujemy reka logike `issueDelegated`
+Then logika idempotencji `findByConfigAndExternalOid -> isIssuedDelegatedInvoice -> findRemoteInvoiceByOid -> insertDelegatedPending -> createInvoice -> finalizeDelegatedExternal` jest zachowana 1:1
+And plik testowy NIE zmienia sie (zero modyfikacji)
+```
+
+## AC-5: Dokumentacja zaktualizowana
+```gherkin
+Given refaktor zmienia strukture modulu Accounting
+When zakonczy sie praca
+Then `.paul/codebase/architecture.md` (tabela "Moduly domenowe", wiersz `Accounting`) wymienia nowe klasy
+And `.paul/codebase/quality_risks.md` (wpis InvoiceService.php) jest oznaczony `~~~~ -> ` ze wskazaniem SUMMARY
+And `.paul/codebase/tech_changelog.md` zawiera wpis z data 2026-05-20 i krotkim opisem
+```
+
+
+
+
+
+
+ Task 1: Wyodrebnij InvoiceMetadataResolver (resolvery dat i referencji)
+ src/Modules/Accounting/InvoiceMetadataResolver.php
+
+ Utworz `src/Modules/Accounting/InvoiceMetadataResolver.php`:
+ - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class InvoiceMetadataResolver`.
+ - Skopiuj 5 metod z `InvoiceService` jako PUBLICZNE (sygnatura 1:1, ciala bez zmian):
+ - `resolveIssueDate(string $override): string`
+ - `resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string`
+ - `resolvePaymentDueDate(string $issueDate, int $paymentToDays): string`
+ - `resolveOrderReference(array $config, array $order): string`
+ - `resolveFakturowniaOid(int $orderId, array $order): string`
+ - Brak konstruktora (bezstanowa klasa) lub pusty publiczny `__construct()` — wybierz wariant bezkonstruktorowy.
+ - Zachowaj wszystkie PHPDoc i komentarze z oryginalu.
+
+ `php -l src/Modules/Accounting/InvoiceMetadataResolver.php` -> No syntax errors; `wc -l` ~100-120.
+ AC-2, AC-3.
+
+
+
+ Task 2: Wyodrebnij InvoiceSnapshotBuilder (seller/buyer/items + ekstrakcja NIP)
+ src/Modules/Accounting/InvoiceSnapshotBuilder.php
+
+ Utworz `src/Modules/Accounting/InvoiceSnapshotBuilder.php`:
+ - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class InvoiceSnapshotBuilder`.
+ - Konstruktor: `public function __construct(private readonly \App\Modules\Settings\CompanySettingsRepository $companySettings) {}`.
+ - Publiczne metody (sygnatury 1:1 z `InvoiceService`):
+ - `buildSellerSnapshot(): array` (uzywa wstrzyknietego `companySettings`).
+ - `buildBuyerSnapshot(array $order, array $addresses, array $params): ?array`.
+ - `buildItemsSnapshot(array $items, array $order): array` (zwraca `['items', 'total_gross', 'total_net']`).
+ - `public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string` (bez zmian).
+ - `private static function taxNumberPaths(): array`, `private static function digValue(array $arr, array $path): mixed` — przeniesione 1:1.
+ - W `buildBuyerSnapshot` zachowaj wywolanie `self::extractBuyerTaxNumber(...)`.
+ - Zachowaj wszystkie PHPDoc-i.
+
+ `php -l src/Modules/Accounting/InvoiceSnapshotBuilder.php` -> No syntax errors; `wc -l` ~220-240.
+ AC-2, AC-3.
+
+
+
+ Task 3: Wyodrebnij FakturowniaInvoicePayloadBuilder
+ src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php
+
+ Utworz `src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php`:
+ - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class FakturowniaInvoicePayloadBuilder`.
+ - Bezstanowa: brak konstruktora.
+ - Publiczna metoda `build(string $kind, string $issueDate, string $saleDate, string $paymentDueDate, array $sellerSnapshot, ?array $buyerSnapshot, array $itemsSnapshot, string $externalOid, string $orderReference, int $paymentToDays, string $departmentId): array` — cialo 1:1 z `InvoiceService::buildFakturowniaPayload`.
+ - **KRYTYCZNE: zachowaj komentarze "UWAGA: seller_* pola CELOWO pominiete..." i "department_id celowo pominiete..."** (~25 lin. komentarzy w sumie). Te komentarze dokumentuja realne ograniczenia API Fakturowni (HTTP 422) — bez nich kolejny refaktor moze "naprawic" je w zly sposob.
+ - Zachowaj `unset($paymentToDays, $sellerSnapshot);` i `unset($departmentId);` — sygnalizuja celowe odrzucenie parametrow.
+
+ `php -l src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php` -> No syntax errors; `wc -l` ~80-100; grep -c "CELOWO" >= 1.
+ AC-2, AC-3.
+
+
+
+ Task 4: Wyodrebnij DelegatedInvoiceIssuer (idempotentna sciezka Fakturowni)
+ src/Modules/Accounting/DelegatedInvoiceIssuer.php
+
+ Utworz `src/Modules/Accounting/DelegatedInvoiceIssuer.php`:
+ - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class DelegatedInvoiceIssuer`.
+ - Konstruktor: `__construct(private readonly InvoiceRepository $invoices, private readonly \App\Modules\Settings\FakturowniaIntegrationRepository $fakturownia, private readonly \App\Modules\Settings\FakturowniaApiClient $fakturowniaApi, private readonly FakturowniaInvoicePayloadBuilder $payloadBuilder)`.
+ - Publiczna metoda `issue(int $orderId, int $configId, array $config, string $issueDate, string $saleDate, string $paymentDueDate, array $sellerSnapshot, ?array $buyerSnapshot, array $itemsSnapshot, float $totalNet, float $totalGross, string $externalOid, string $orderReference, string $kind, ?int $createdBy): array` — cialo z `InvoiceService::issueDelegated` (linie 175-273), zamieniajac:
+ - `$this->fakturownia` -> `$this->fakturownia` (juz pole),
+ - `$this->buildFakturowniaPayload(...)` -> `$this->payloadBuilder->build(...)`,
+ - `$this->fakturowniaApi->createInvoice(...)` -> `$this->fakturowniaApi->createInvoice(...)` (pole),
+ - `$this->invoices->...` -> `$this->invoices->...` (pole).
+ - Prywatne metody przeniesione 1:1: `buildDelegatedInvoiceData`, `findRemoteInvoiceByOid`, `attachRemoteInvoice`, `finalizeDelegatedInvoice`, `isIssuedDelegatedInvoice`, `resultFromLocalDelegatedInvoice`.
+ - Zachowaj logike rzucania `InvoiceIssueException` (komunikat z propozycja "Sprobuj ponownie - orderPRO uzyje tego samego oid...").
+
+ `php -l src/Modules/Accounting/DelegatedInvoiceIssuer.php` -> No syntax errors; `wc -l` ~180-200; grep "InvoiceIssueException" >= 4 (4 miejsca rzucania).
+ AC-2, AC-3, AC-4.
+
+
+
+ Task 5: Przepisz InvoiceService na slim fasade
+ src/Modules/Accounting/InvoiceService.php
+
+ Przepisz `src/Modules/Accounting/InvoiceService.php` (~150 lin.):
+ - Zachowaj `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class InvoiceService`.
+ - **Konstruktor BEZ ZMIAN**: `__construct(InvoiceRepository $invoices, InvoiceConfigRepository $invoiceConfigs, CompanySettingsRepository $companySettings, OrdersRepository $orders, FakturowniaIntegrationRepository $fakturownia, FakturowniaApiClient $fakturowniaApi)`.
+ - W ciele konstruktora zbuduj wspolpracownikow:
+ ```
+ $this->metadata = new InvoiceMetadataResolver();
+ $this->snapshots = new InvoiceSnapshotBuilder($companySettings);
+ $payloadBuilder = new FakturowniaInvoicePayloadBuilder();
+ $this->delegated = new DelegatedInvoiceIssuer($invoices, $fakturownia, $fakturowniaApi, $payloadBuilder);
+ ```
+ - Pola prywatne dla nadal uzywanych zaleznosci: `$invoices`, `$invoiceConfigs`, `$orders` + nowe `$metadata`, `$snapshots`, `$delegated`.
+ - `public function issue(array $params): array` — orchestracja (sygnatura + zwrot 1:1):
+ 1. walidacja `config` (`invoiceConfigs->findById` + `is_active`) — rzuca `InvoiceIssueException`,
+ 2. lookup `orders->findDetails` — rzuca `InvoiceIssueException`,
+ 3. wywola `$this->metadata->resolveIssueDate/...SaleDate/...PaymentDueDate/...OrderReference/...FakturowniaOid`,
+ 4. wywola `$this->snapshots->buildSellerSnapshot/...BuyerSnapshot` + destrukturyzacja `buildItemsSnapshot`,
+ 5. galez `$isDelegated` -> `$this->delegated->issue(...)` z pelnym zestawem 15 argumentow,
+ 6. galez local -> wywola lokalna prywatna `issueLocal(...)` (zachowana w fasadzie jako 1 metoda ~50 lin., bo nie wymaga osobnej klasy — wstrzykuje tylko `$this->invoices`).
+ - `private function issueLocal(...): array` — 1:1 z oryginalu.
+ - `public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string { return InvoiceSnapshotBuilder::extractBuyerTaxNumber($order, $buyerAddress); }` — forwarder zachowujacy publiczna statyczna API.
+ - Usun z fasady wszystkie pozostale prywatne metody (przeniesione do wspolpracownikow).
+ - Zachowaj PHPDoc na `issue()` (kontrakt `array{order_id, config_id, buyer_*, ...}` -> `array{invoice_number, total_gross, mode, invoice_id}`).
+
+ `php -l src/Modules/Accounting/InvoiceService.php` -> No syntax errors; `wc -l` <= 160; grep "public function" -> 2 (issue + statyk); grep "private function" -> 1 (issueLocal).
+ AC-1, AC-3.
+
+
+
+ Task 6: Weryfikacja integracyjna (controller + module + test + smoke)
+ src/Modules/Accounting/InvoiceController.php, src/Modules/Accounting/AccountingModule.php, tests/Unit/FakturowniaInvoiceIdempotencyTest.php
+
+ NIE modyfikuj tych plikow. Wykonaj weryfikacje:
+ 1. `php -l` na 5 plikach: `InvoiceService.php` + 4 nowe klasy + `InvoiceController.php` + `AccountingModule.php` (lint = primary gate w sesji bez DB).
+ 2. `grep -n "InvoiceService::extractBuyerTaxNumber" src/Modules/Accounting/InvoiceController.php` — potwierdzic, ze wolanie statycznej `extractBuyerTaxNumber` nadal kompiluje (zwraca string z `InvoiceSnapshotBuilder`).
+ 3. `grep -n "new InvoiceService" src/Modules/Accounting/AccountingModule.php tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — potwierdzic ze konstruktor 6-argumentowy nadal jest poprawny.
+ 4. Reflection-check (gdy `php` dostepne): `php -r "$r = new ReflectionClass('App\\Modules\\Accounting\\InvoiceService'); var_dump($r->getConstructor()->getNumberOfParameters());"` — oczekiwane 6 (autoload trzeba zaladowac przez `vendor/autoload.php` jezeli istnieje).
+ 5. Jezeli `vendor/phpunit` jest dostepny: `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 3/3 OK. W przeciwnym razie zapisz w SUMMARY jako "do weryfikacji recznej".
+ 6. Manualny przeglad sciezki controlowanej: czy w `InvoiceController::create` (POST /orders/{id}/invoice/create) zwracane przez `issue()` `invoice_id/invoice_number/total_gross/mode` sa konsumowane bez zmian.
+
+ 5/5 plikow `php -l` -> No syntax errors; konsumenci `extractBuyerTaxNumber` i `new InvoiceService(...)` niezmienieni.
+ AC-1, AC-4.
+
+
+
+ Task 7: Aktualizacja dokumentacji `.paul/codebase/`
+ .paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md
+
+ 1. `architecture.md` — w tabeli "Moduly domenowe", wiersz `Accounting`, rozszerz opis o:
+ `slim `InvoiceService` (fasada) + `InvoiceMetadataResolver` (resolvery dat/oid) + `InvoiceSnapshotBuilder` (seller/buyer/items + extractBuyerTaxNumber) + `FakturowniaInvoicePayloadBuilder` (payload API) + `DelegatedInvoiceIssuer` (idempotentna sciezka Fakturowni)`.
+ 2. `quality_risks.md` — wpis `| src/Modules/Accounting/InvoiceService.php | 762 | |` przerob na:
+ `| ~~src/Modules/Accounting/InvoiceService.php~~ | ~~762~~ -> | ✅ Zrefaktorowane 2026-05-20 — fasada + 4 wspolpracownikow. Patrz .paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md. |`.
+ 3. `tech_changelog.md` — dopisz wpis 2026-05-20 z 3-4 punktami: dekompozycja, zachowanie publicznego kontraktu, test idempotencji bez zmian, wzorzec spojny z DeliveryStatus/Automation/Allegro.
+
+ `grep -c "InvoiceMetadataResolver" .paul/codebase/architecture.md` >= 1; `grep "20260520-1500-refactor-invoice-service" .paul/codebase/quality_risks.md` >= 1.
+ AC-5.
+
+
+
+
+
+## Nie zmieniac
+- Sygnatury konstruktora `InvoiceService` (6 zaleznosci w obecnej kolejnosci).
+- Sygnatury `InvoiceService::issue(array $params): array` i ksztaltu zwracanej tablicy `{invoice_id, invoice_number, total_gross, mode}`.
+- Sygnatury statycznej `InvoiceService::extractBuyerTaxNumber(array $order, ?array $buyerAddress): string`.
+- Pliku `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` (primary gate — zero modyfikacji).
+- Pliku `src/Modules/Accounting/InvoiceController.php` (728 lin., osobny kandydat na refaktor — out of scope).
+- Pliku `src/Modules/Accounting/InvoiceRepository.php` (zero zmian).
+- `src/Modules/Accounting/AccountingModule.php` w zakresie wpisu `accounting.invoices.service` (zero zmian w `new InvoiceService(...)`).
+- Schematu tabeli `invoices` i payloadu wysylanego do Fakturowni (`buildFakturowniaPayload` -> `FakturowniaInvoicePayloadBuilder::build` musi byc 1:1; w szczegolnosci celowy brak `seller_*` i `department_id`).
+- Komunikatow `InvoiceIssueException` (wlacznie z tekstem "Sprobuj ponownie - orderPRO uzyje tego samego oid...").
+
+## Scope limits
+- Refaktor `InvoiceController` — out of scope (osobny plan).
+- Refaktor `InvoiceRepository` — out of scope.
+- Dodawanie nowych testow jednostkowych dla wyodrebnionych klas — out of scope (deferred do osobnego planu jezeli sie zdecydujemy).
+- Ujednolicenie wzorca `(string)($x[..] ?? '')` / `trim(...)` — celowo nie ruszamy.
+- Migracja `InvoiceController` z `InvoiceService::extractBuyerTaxNumber` na bezposrednie wywolanie nowej klasy — osobny mikroplan.
+
+
+
+- [ ] `php -l` zwraca `No syntax errors` dla 5 plikow: `InvoiceService.php`, `InvoiceMetadataResolver.php`, `InvoiceSnapshotBuilder.php`, `FakturowniaInvoicePayloadBuilder.php`, `DelegatedInvoiceIssuer.php`.
+- [ ] `wc -l src/Modules/Accounting/InvoiceService.php` <= 160.
+- [ ] `grep -n "public function" src/Modules/Accounting/InvoiceService.php` zwraca tylko `__construct`, `issue`, `extractBuyerTaxNumber` (statyk).
+- [ ] `grep -n "InvoiceService::extractBuyerTaxNumber" src/Modules/Accounting/InvoiceController.php` — wystepuje 1x, niezmienione.
+- [ ] `grep -n "new InvoiceService(" src/Modules/Accounting/AccountingModule.php tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 2 wystapienia, oba z 6 argumentami.
+- [ ] Reflection: `InvoiceService::class` ma konstruktor o 6 parametrach.
+- [ ] (Opcjonalnie, gdy `vendor/phpunit` dostepny) `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` -> 3/3 OK.
+- [ ] Quality Radar ryzyko `InvoiceService.php 762 lin.` przeniesione do "Zrefaktorowane" w `quality_risks.md`.
+
+
+
+- [ ] Wszystkie AC (AC-1..AC-5) spelnione.
+- [ ] Verification checklist pozytywna (z wyjatkiem PHPUnit jezeli vendor niedostepny — wtedy zapisac jako "do weryfikacji recznej" w SUMMARY).
+- [ ] Dokumentacja `.paul/codebase/` zaktualizowana.
+- [ ] Brak modyfikacji w `InvoiceController.php`, `InvoiceRepository.php`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`.
+- [ ] Petla zamknieta przez UNIFY z SUMMARY.md.
+
+
+
diff --git a/.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md b/.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md
new file mode 100644
index 0000000..b5d2df9
--- /dev/null
+++ b/.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md
@@ -0,0 +1,110 @@
+---
+plan_id: 20260520-1500-refactor-invoice-service
+title: Dekompozycja InvoiceService (762 -> 118 lin., fasada + 5 wspolpracownikow)
+completed: 2026-05-20T15:45:00+02:00
+storage: plan-first
+quality_radar: degraded
+---
+
+# Summary: Dekompozycja `InvoiceService`
+
+## Objective
+
+Zredukowac `src/Modules/Accounting/InvoiceService.php` z 762 lin. monolitu lacacego 6 odpowiedzialnosci do slim fasady (~150) + wspolpracownikow w `src/Modules/Accounting/`, zachowujac pelny publiczny kontrakt (konstruktor 6-arg, `issue(array)`, statyk `extractBuyerTaxNumber`).
+
+## What Was Built
+
+| Obszar | Wynik |
+|---|---|
+| Fasada `InvoiceService` | 762 -> **118 lin.** (84% redukcji), 1 publiczna metoda + 1 statyk forwarder, deleguje do 5 wspolpracownikow |
+| `InvoiceMetadataResolver` (NOWA, 84 lin.) | 5x `resolve*` (issueDate / saleDate / paymentDueDate / orderReference / fakturowniaOid), bezstanowa |
+| `InvoiceSnapshotBuilder` (NOWA, 223 lin.) | seller/buyer/items snapshots + publiczna statyka `extractBuyerTaxNumber` + prywatne helpery `taxNumberPaths`/`digValue`, zalezy od `CompanySettingsRepository` |
+| `FakturowniaInvoicePayloadBuilder` (NOWA, 84 lin.) | `build()` z zachowanymi komentarzami "CELOWO/celowo pominiete" (seller_*, department_id — HTTP 422 ograniczenia API) |
+| `LocalInvoiceIssuer` (NOWA, 66 lin.) | sciezka lokalna (`nextLocalNumber` + `insertLocal`), zalezy od `InvoiceRepository` — dodana ponad plan dla AC-3 (symetria do delegated) |
+| `DelegatedInvoiceIssuer` (NOWA, 253 lin.) | idempotentna sciezka Fakturowni: `findByConfigAndExternalOid -> isIssuedDelegatedInvoice -> findRemoteInvoiceByOid -> insertDelegatedPending -> createInvoice -> finalizeDelegatedExternal`, 7x rzucenie `InvoiceIssueException` |
+
+## Files Modified
+
+- `src/Modules/Accounting/InvoiceService.php` — przepisany na fasade (118 lin.).
+- `src/Modules/Accounting/InvoiceMetadataResolver.php` — nowy (84).
+- `src/Modules/Accounting/InvoiceSnapshotBuilder.php` — nowy (223).
+- `src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php` — nowy (84).
+- `src/Modules/Accounting/LocalInvoiceIssuer.php` — nowy (66, deviation).
+- `src/Modules/Accounting/DelegatedInvoiceIssuer.php` — nowy (253).
+- `.paul/codebase/architecture.md` — wpis `Accounting` rozszerzony o nowe klasy.
+- `.paul/codebase/quality_risks.md` — wpis `InvoiceService.php` oznaczony jako zrefaktorowany.
+- `.paul/codebase/tech_changelog.md` — nowy wpis 2026-05-20 (na gorze).
+- `.paul/codebase/tooling_status.md` — sekcja post-apply.
+
+**Bez modyfikacji (boundary):** `InvoiceController.php`, `AccountingModule.php`, `InvoiceRepository.php`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`.
+
+## Acceptance Criteria Results
+
+| Criterion | Status | Evidence |
+|---|---|---|
+| AC-1: Fasada zachowuje publiczny kontrakt | ✅ Pass | `php -l` zielony; `grep` potwierdza `InvoiceController.php:97` (`InvoiceService::extractBuyerTaxNumber`) i `AccountingModule.php:66` (`new InvoiceService(...)`) niezmienione; test idempotencji `createService()` montuje 6-arg konstruktor bez zmian |
+| AC-2: 4 nowe klasy wyodrebnione | ✅ Pass (+1) | Wszystkie 4 zaplanowane + dodatkowa `LocalInvoiceIssuer` (deviation dla AC-3). `php -l` zielony dla 5/5 nowych klas |
+| AC-3: `InvoiceService` <= 160 lin. | ✅ Pass | 118 lin. (poniżej celu z duzym zapasem) |
+| AC-4: Test idempotencji bez zmian | ⏳ Pending UAT | `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` zero modyfikacji; logika `issueDelegated` przeniesiona 1:1 do `DelegatedInvoiceIssuer::issue()`. Faktyczne uruchomienie testu wymaga `vendor/phpunit` (niedostepne w sesji) |
+| AC-5: Dokumentacja zaktualizowana | ✅ Pass | `architecture.md`, `quality_risks.md`, `tech_changelog.md`, `tooling_status.md` zaktualizowane |
+
+## Verification Results
+
+| Check | Result | Notes |
+|---|---|---|
+| `php -l src/Modules/Accounting/InvoiceService.php` | ✅ Pass | No syntax errors |
+| `php -l` x 4 nowe klasy + `LocalInvoiceIssuer` | ✅ Pass | 5/5 No syntax errors |
+| `php -l InvoiceController.php` + `AccountingModule.php` | ✅ Pass | konsumenci kompiluja sie z nowym kontraktem |
+| Reflection probe (`InvoiceMetadataResolver`, `FakturowniaInvoicePayloadBuilder`) | ✅ Pass | bezstanowe klasy ladowalne bez autoload |
+| `wc -l src/Modules/Accounting/InvoiceService.php` | ✅ Pass | 118 lin. (cel <=160) |
+| `grep -c "CELOWO" FakturowniaInvoicePayloadBuilder.php` | ✅ Pass | komentarz dot. HTTP 422 zachowany |
+| `grep "InvoiceService::extractBuyerTaxNumber" InvoiceController.php` | ✅ Pass | 1 trafienie, niezmienione |
+| `grep "new InvoiceService(" AccountingModule.php tests/...` | ✅ Pass | 2 trafienia, oba 6-arg |
+| `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` | ⏳ Skipped | vendor/phpunit niedostepny — do recznego UAT |
+| Smoke `POST /orders/{id}/invoice/create` | ⏳ Skipped | DB niedostepna w sesji — do recznego UAT |
+
+## Quality Radar Results
+
+**Status:** degraded (cz. tools niedostepne w sesji APPLY/UNIFY)
+
+- **codebase-memory-mcp:** uzyte w PLAN (search_graph, lokalizacja konsumentow). Re-index po refaktorze nie uruchomiony — graf zostanie odswiezony przy nastepnym `$paul-map-codebase`.
+- **jscpd / ast-grep:** disabled by policy (`.paul/config.md`).
+- **vendor/phpunit:** niedostepne w sesji.
+- **php (XAMPP):** dostepne, lint zielony dla 7 plikow.
+
+### Risks delta
+
+- **Resolved:** `InvoiceService.php` (762 lin.) — usuniety z listy >500 lin. w `quality_risks.md`.
+- **New:** brak nowych ryzyk. Wzorzec dekompozycji spojny z poprzednimi refaktorami (DeliveryStatus, AutomationController, AllegroIntegrationController).
+- **Deferred:**
+ - Pelne testy jednostkowe dla `InvoiceMetadataResolver`, `InvoiceSnapshotBuilder`, `LocalInvoiceIssuer` — out of scope.
+ - Migracja `InvoiceController.php` z `InvoiceService::extractBuyerTaxNumber` na bezposrednie wolanie `InvoiceSnapshotBuilder::extractBuyerTaxNumber` — wstecznie kompatybilny forwarder wystarcza.
+ - Refaktor `InvoiceController.php` (293 lin. po niniejszych zmianach — przedtem podobnie) i `InvoiceRepository.php` — osobne plany.
+
+## Deviations
+
+1. **Dodano 5-ta klase `LocalInvoiceIssuer` (poza planem):** Plan zakladal 4 nowe klasy + `issueLocal` jako prywatna metoda w fasadzie. Po pierwszej iteracji fasada miala 190 lin. (powyzej celu AC-3 <=160). Wyciagniecie `issueLocal` do osobnej klasy `LocalInvoiceIssuer` (66 lin.) dalo symetrie do `DelegatedInvoiceIssuer` i zejscie do 118 lin. fasady. Pozytywny side-effect: identyczna kolejnosc parametrow w obu issuerach ulatwia czytelnosc.
+
+2. **Incydentalna naprawa pre-existing bugu:** Oryginalny `InvoiceService::issueLocal()` mial 15 parametrow w sygnaturze (w tym nieuzywany `string $externalOid` na pozycji 12), a wywolanie z `issue()` przekazywalo 14 argumentow — co dawalo `ArgumentCountError` przy kazdej probie wystawienia faktury w trybie lokalnym (`is_delegated=0`). Refaktor wprowadzil `LocalInvoiceIssuer::issue` z prawidlowymi 14 parametrami (brak vestigialnego `externalOid`) i spojnym wywolaniem z fasady. Sciezka local teraz wlasciwie dziala, ale nie ma jej w testach — wlasciwy regression test do dolozenia w osobnym planie. **Risk:** uzytkownicy ktorzy probowali wystawic fakture lokalnie (rzadko — wiekszosc uzywa Fakturowni) dotad dostawali blad; teraz dostana faktyczne wystawienie. Sygnalizujemy w changelogu.
+
+3. **Quality Radar `quality_radar: degraded` zamiast `ok`** zadeklarowane w frontmatter SUMMARY: w sesji UNIFY nie uruchamialismy reindexu codebase-memory-mcp i nie mielismy dostepu do PHPUnit. Lint + reflection wystarczaja do potwierdzenia kontraktu, ale automatyczna walidacja idempotencji nie zostala wykonana.
+
+## Key Decisions / Patterns
+
+- **Slim fasada z wstrzyknieciem przez konstruktor:** `InvoiceService` jako orchestrator + 5 wspolpracownikow budowanych w konstruktorze (poor man's DI, brak rejestracji w `ServiceRegistry` — to wewnetrzne szczegoly fasady). Spojne z `AutomationController` (3 wspolpracownikow zarejestrowanych w DI) i `DeliveryStatus` (fasada z prywatnymi mapami).
+- **Statyczny forwarder dla wstecznej kompatybilnosci:** `InvoiceService::extractBuyerTaxNumber()` -> `InvoiceSnapshotBuilder::extractBuyerTaxNumber()` zachowuje 1 wolanie z `InvoiceController.php:97` bez modyfikacji kontrolera. Wzorzec do uzycia kiedy migracja konsumenta nie miesci sie w scope refactoru.
+- **Komentarze "CELOWO/celowo pominiete" przeniesione 1:1** do `FakturowniaInvoicePayloadBuilder` — to dokumentacja realnych ograniczen API Fakturowni (HTTP 422), nieintuicyjna bez kontekstu. Bez nich kolejny refaktor moglby "naprawic" zachowanie w zly sposob.
+- **Preserve, then improve (z 1 wyjatkiem):** ciala metod skopiowane 1:1 (z wyjatkiem buga w sygnaturze `issueLocal`, ktory nie mial jak byc "skopiowany 1:1" bo wywolanie nie pasowalo do deklaracji). Decyzja: poprawic deklaracje sygnatury (lepiej dziala niz nie dziala), odnotowac jako observation.
+
+## Follow-up
+
+1. **Reczny UAT (priorytet 1):**
+ - `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 3/3 idempotencji.
+ - Smoke: `POST /orders/{id}/invoice/create` dla konfiguracji `is_delegated=1` (Fakturownia) — sprawdzic, ze faktura jest tworzona i `external_invoice_id`/`external_pdf_url` zapisane.
+ - Smoke: `POST /orders/{id}/invoice/create` dla konfiguracji `is_delegated=0` (lokalna) — teraz po naprawie sygnatury powinno dzialac.
+2. **Commit:** sugerowane podsumowanie `refactor(accounting): dekompozycja InvoiceService 762 -> 118 lin., fasada + 5 wspolpracownikow`.
+3. **Kolejny `$paul-plan` z `quality_risks.md`:**
+ - `src/Modules/Settings/ShopproIntegrationsController.php` (1076 lin.) — kandydat na rozbicie wzorem Allegro.
+ - `src/Modules/Shipments/ApaczkaShipmentService.php` (1044 lin.).
+ - `src/Modules/Orders/OrdersController.php` (1490 lin.) — najwiekszy, ale wymaga dyskusji o granicach (notes / payments / invoice toggle).
+4. **Regression test dla `LocalInvoiceIssuer`** — osobny mikroplan jezeli sciezka local ma byc oficjalnie wspierana.
diff --git a/src/Modules/Accounting/DelegatedInvoiceIssuer.php b/src/Modules/Accounting/DelegatedInvoiceIssuer.php
new file mode 100644
index 0000000..1e68fbc
--- /dev/null
+++ b/src/Modules/Accounting/DelegatedInvoiceIssuer.php
@@ -0,0 +1,253 @@
+ $config
+ * @param array $sellerSnapshot
+ * @param array|null $buyerSnapshot
+ * @param list> $itemsSnapshot
+ * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
+ * @throws InvoiceIssueException
+ */
+ public function issue(
+ int $orderId,
+ int $configId,
+ array $config,
+ string $issueDate,
+ string $saleDate,
+ string $paymentDueDate,
+ array $sellerSnapshot,
+ ?array $buyerSnapshot,
+ array $itemsSnapshot,
+ float $totalNet,
+ float $totalGross,
+ string $externalOid,
+ 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('Globalna konfiguracja Fakturowni nie istnieje (id=' . $integrationId . ').');
+ }
+
+ $prefix = trim((string) ($account['account_prefix'] ?? ''));
+ if ($prefix === '') {
+ throw new InvoiceIssueException('Globalna konfiguracja Fakturowni 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->payloadBuilder->build(
+ $kind,
+ $issueDate,
+ $saleDate,
+ $paymentDueDate,
+ $sellerSnapshot,
+ $buyerSnapshot,
+ $itemsSnapshot,
+ $externalOid,
+ $orderReference,
+ (int) ($config['payment_to_days'] ?? 7),
+ (string) ($account['department_id'] ?? '')
+ );
+
+ $apiSettings = [
+ 'account_prefix' => $prefix,
+ 'api_token' => $apiToken,
+ ];
+ $pendingData = $this->buildDelegatedInvoiceData(
+ $orderId,
+ $configId,
+ $issueDate,
+ $saleDate,
+ $paymentDueDate,
+ $sellerSnapshot,
+ $buyerSnapshot,
+ $itemsSnapshot,
+ $totalNet,
+ $totalGross,
+ $externalOid,
+ $kind,
+ $createdBy
+ );
+
+ $local = $this->invoices->findByConfigAndExternalOid($configId, $externalOid);
+ if ($this->isIssuedDelegatedInvoice($local)) {
+ return $this->resultFromLocalDelegatedInvoice($local);
+ }
+
+ $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid);
+ if ($remote !== null) {
+ return $this->attachRemoteInvoice($local, $pendingData, $remote);
+ }
+
+ $invoiceId = $local !== null
+ ? (int) ($local['id'] ?? 0)
+ : $this->invoices->insertDelegatedPending($pendingData);
+
+ try {
+ $response = $this->fakturowniaApi->createInvoice($apiSettings, $payload);
+ } catch (Throwable $e) {
+ $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid);
+ if ($remote !== null) {
+ return $this->finalizeDelegatedInvoice($invoiceId, $remote);
+ }
+
+ $message = 'Fakturownia: ' . $e->getMessage();
+ $this->invoices->markDelegatedExternalFailed($invoiceId, $message);
+ throw new InvoiceIssueException($message . ' Spróbuj ponownie - orderPRO użyje tego samego oid i najpierw sprawdźi Fakturownie.');
+ }
+
+ return $this->finalizeDelegatedInvoice($invoiceId, $response);
+ }
+
+ /**
+ * @param array $sellerSnapshot
+ * @param array|null $buyerSnapshot
+ * @param list> $itemsSnapshot
+ * @return array
+ */
+ private function buildDelegatedInvoiceData(
+ int $orderId,
+ int $configId,
+ string $issueDate,
+ string $saleDate,
+ string $paymentDueDate,
+ array $sellerSnapshot,
+ ?array $buyerSnapshot,
+ array $itemsSnapshot,
+ float $totalNet,
+ float $totalGross,
+ string $externalOid,
+ string $kind,
+ ?int $createdBy
+ ): array {
+ return [
+ 'order_id' => $orderId,
+ 'config_id' => $configId,
+ '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, '.', ''),
+ 'external_oid' => $externalOid,
+ 'kind' => $kind,
+ 'created_by' => $createdBy,
+ ];
+ }
+
+ /**
+ * @param array $settings
+ * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array}|null
+ */
+ private function findRemoteInvoiceByOid(array $settings, string $externalOid): ?array
+ {
+ try {
+ return $this->fakturowniaApi->findInvoiceByOid($settings, $externalOid);
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * @param array|null $local
+ * @param array $pendingData
+ * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote
+ * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
+ */
+ private function attachRemoteInvoice(?array $local, array $pendingData, array $remote): array
+ {
+ $invoiceId = $local !== null
+ ? (int) ($local['id'] ?? 0)
+ : $this->invoices->insertDelegatedPending($pendingData);
+
+ return $this->finalizeDelegatedInvoice($invoiceId, $remote);
+ }
+
+ /**
+ * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote
+ * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
+ */
+ private function finalizeDelegatedInvoice(int $invoiceId, array $remote): array
+ {
+ $externalId = trim((string) ($remote['id'] ?? ''));
+ $externalNumber = trim((string) ($remote['number'] ?? ''));
+ $externalPdfUrl = trim((string) ($remote['pdf_url'] ?? $remote['view_url'] ?? ''));
+
+ if ($externalId === '' || $externalNumber === '') {
+ throw new InvoiceIssueException('Fakturownia zwróciła niekompletną odpowiedź (brak id/number).');
+ }
+
+ $this->invoices->finalizeDelegatedExternal($invoiceId, [
+ 'invoice_number' => $externalNumber,
+ 'external_invoice_id' => $externalId,
+ 'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null,
+ ]);
+
+ $invoice = $this->invoices->findById($invoiceId);
+ $totalGross = is_array($invoice) ? (float) ($invoice['total_gross'] ?? 0) : 0.0;
+
+ return [
+ 'invoice_id' => $invoiceId,
+ 'invoice_number' => $externalNumber,
+ 'total_gross' => number_format($totalGross, 2, '.', ''),
+ 'mode' => 'delegated',
+ ];
+ }
+
+ /**
+ * @param array|null $invoice
+ */
+ private function isIssuedDelegatedInvoice(?array $invoice): bool
+ {
+ if ($invoice === null) {
+ return false;
+ }
+
+ return trim((string) ($invoice['external_invoice_id'] ?? '')) !== ''
+ && trim((string) ($invoice['invoice_number'] ?? '')) !== ''
+ && (string) ($invoice['external_status'] ?? 'issued') !== 'pending_external';
+ }
+
+ /**
+ * @param array $invoice
+ * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
+ */
+ private function resultFromLocalDelegatedInvoice(array $invoice): array
+ {
+ return [
+ 'invoice_id' => (int) ($invoice['id'] ?? 0),
+ 'invoice_number' => (string) ($invoice['invoice_number'] ?? ''),
+ 'total_gross' => number_format((float) ($invoice['total_gross'] ?? 0), 2, '.', ''),
+ 'mode' => 'delegated',
+ ];
+ }
+}
diff --git a/src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php b/src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php
new file mode 100644
index 0000000..e5cd995
--- /dev/null
+++ b/src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php
@@ -0,0 +1,84 @@
+ $sellerSnapshot
+ * @param array|null $buyerSnapshot
+ * @param list> $itemsSnapshot
+ * @return array
+ */
+ public function build(
+ string $kind,
+ string $issueDate,
+ string $saleDate,
+ string $paymentDueDate,
+ array $sellerSnapshot,
+ ?array $buyerSnapshot,
+ array $itemsSnapshot,
+ string $externalOid,
+ 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 pominięte. 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 (użytkownik 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',
+ 'oid' => $externalOid,
+ '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'] ?? '');
+ }
+
+ $descriptionReference = $orderReference !== '' ? $orderReference : $externalOid;
+ if ($descriptionReference !== '') {
+ $invoice['additional_info_desc'] = 'Zamówienie: ' . $descriptionReference;
+ }
+
+ // department_id celowo pominięte — 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/Accounting/InvoiceMetadataResolver.php b/src/Modules/Accounting/InvoiceMetadataResolver.php
new file mode 100644
index 0000000..54957cc
--- /dev/null
+++ b/src/Modules/Accounting/InvoiceMetadataResolver.php
@@ -0,0 +1,84 @@
+ $config
+ * @param array $order
+ * @param list> $payments
+ */
+ public 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;
+ }
+
+ public 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
+ */
+ public 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 '';
+ }
+
+ /**
+ * @param array $order
+ */
+ public function resolveFakturowniaOid(int $orderId, array $order): string
+ {
+ $internalNumber = trim((string) ($order['internal_order_number'] ?? ''));
+ if ($internalNumber !== '') {
+ return $internalNumber;
+ }
+
+ return 'orderpro-' . $orderId;
+ }
+}
diff --git a/src/Modules/Accounting/InvoiceService.php b/src/Modules/Accounting/InvoiceService.php
index cd38827..aeacc31 100644
--- a/src/Modules/Accounting/InvoiceService.php
+++ b/src/Modules/Accounting/InvoiceService.php
@@ -8,18 +8,31 @@ use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\FakturowniaApiClient;
use App\Modules\Settings\FakturowniaIntegrationRepository;
use App\Modules\Settings\InvoiceConfigRepository;
-use Throwable;
final class InvoiceService
{
+ private readonly InvoiceMetadataResolver $metadata;
+ private readonly InvoiceSnapshotBuilder $snapshots;
+ private readonly LocalInvoiceIssuer $local;
+ private readonly DelegatedInvoiceIssuer $delegated;
+
public function __construct(
- private readonly InvoiceRepository $invoices,
+ InvoiceRepository $invoices,
private readonly InvoiceConfigRepository $invoiceConfigs,
- private readonly CompanySettingsRepository $companySettings,
+ CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders,
- private readonly FakturowniaIntegrationRepository $fakturownia,
- private readonly FakturowniaApiClient $fakturowniaApi
+ FakturowniaIntegrationRepository $fakturownia,
+ FakturowniaApiClient $fakturowniaApi
) {
+ $this->metadata = new InvoiceMetadataResolver();
+ $this->snapshots = new InvoiceSnapshotBuilder($companySettings);
+ $this->local = new LocalInvoiceIssuer($invoices);
+ $this->delegated = new DelegatedInvoiceIssuer(
+ $invoices,
+ $fakturownia,
+ $fakturowniaApi,
+ new FakturowniaInvoicePayloadBuilder()
+ );
}
/**
@@ -59,704 +72,47 @@ final class InvoiceService
$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);
- $externalOid = $this->resolveFakturowniaOid($orderId, $order);
+ $issueDate = $this->metadata->resolveIssueDate((string) ($params['issue_date_override'] ?? ''));
+ $saleDate = $this->metadata->resolveSaleDate($config, $order, $payments, $issueDate);
+ $paymentDueDate = $this->metadata->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7));
+ $orderReference = $this->metadata->resolveOrderReference($config, $order);
+ $externalOid = $this->metadata->resolveFakturowniaOid($orderId, $order);
- $sellerSnapshot = $this->buildSellerSnapshot();
- $buyerSnapshot = $this->buildBuyerSnapshot($order, $addresses, $params);
- ['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->buildItemsSnapshot($items, $order);
+ $sellerSnapshot = $this->snapshots->buildSellerSnapshot();
+ $buyerSnapshot = $this->snapshots->buildBuyerSnapshot($order, $addresses, $params);
+ ['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->snapshots->buildItemsSnapshot($items, $order);
$kind = trim((string) ($config['default_kind'] ?? 'vat')) ?: 'vat';
- $isDelegated = (int) ($config['is_delegated'] ?? 0) === 1;
+ $createdBy = $params['created_by'] ?? null;
- if ($isDelegated) {
- return $this->issueDelegated(
- $orderId,
- $configId,
- $config,
- $issueDate,
- $saleDate,
- $paymentDueDate,
- $sellerSnapshot,
- $buyerSnapshot,
- $itemsSnapshot,
- $totalNet,
- $totalGross,
- $externalOid,
- $orderReference,
- $kind,
- $params['created_by'] ?? null
+ if ((int) ($config['is_delegated'] ?? 0) === 1) {
+ return $this->delegated->issue(
+ $orderId, $configId, $config,
+ $issueDate, $saleDate, $paymentDueDate,
+ $sellerSnapshot, $buyerSnapshot, $itemsSnapshot,
+ $totalNet, $totalGross,
+ $externalOid, $orderReference, $kind, $createdBy
);
}
- return $this->issueLocal(
- $orderId,
- $configId,
- $config,
- $issueDate,
- $saleDate,
- $paymentDueDate,
- $sellerSnapshot,
- $buyerSnapshot,
- $itemsSnapshot,
- $totalNet,
- $totalGross,
- $orderReference,
- $kind,
- $params['created_by'] ?? null
+ return $this->local->issue(
+ $orderId, $configId, $config,
+ $issueDate, $saleDate, $paymentDueDate,
+ $sellerSnapshot, $buyerSnapshot, $itemsSnapshot,
+ $totalNet, $totalGross,
+ $orderReference, $kind, $createdBy
);
}
/**
- * @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 $externalOid,
- 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('Globalna konfiguracja Fakturowni nie istnieje (id=' . $integrationId . ').');
- }
-
- $prefix = trim((string) ($account['account_prefix'] ?? ''));
- if ($prefix === '') {
- throw new InvoiceIssueException('Globalna konfiguracja Fakturowni 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,
- $externalOid,
- $orderReference,
- (int) ($config['payment_to_days'] ?? 7),
- (string) ($account['department_id'] ?? '')
- );
-
- $apiSettings = [
- 'account_prefix' => $prefix,
- 'api_token' => $apiToken,
- ];
- $pendingData = $this->buildDelegatedInvoiceData(
- $orderId,
- $configId,
- $issueDate,
- $saleDate,
- $paymentDueDate,
- $sellerSnapshot,
- $buyerSnapshot,
- $itemsSnapshot,
- $totalNet,
- $totalGross,
- $externalOid,
- $kind,
- $createdBy
- );
-
- $local = $this->invoices->findByConfigAndExternalOid($configId, $externalOid);
- if ($this->isIssuedDelegatedInvoice($local)) {
- return $this->resultFromLocalDelegatedInvoice($local);
- }
-
- $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid);
- if ($remote !== null) {
- return $this->attachRemoteInvoice($local, $pendingData, $remote);
- }
-
- $invoiceId = $local !== null
- ? (int) ($local['id'] ?? 0)
- : $this->invoices->insertDelegatedPending($pendingData);
-
- try {
- $response = $this->fakturowniaApi->createInvoice($apiSettings, $payload);
- } catch (Throwable $e) {
- $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid);
- if ($remote !== null) {
- return $this->finalizeDelegatedInvoice($invoiceId, $remote);
- }
-
- $message = 'Fakturownia: ' . $e->getMessage();
- $this->invoices->markDelegatedExternalFailed($invoiceId, $message);
- throw new InvoiceIssueException($message . ' Spróbuj ponownie - orderPRO użyje tego samego oid i najpierw sprawdźi Fakturownie.');
- }
-
- return $this->finalizeDelegatedInvoice($invoiceId, $response);
- }
-
- 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 '';
- }
-
- /**
- * @param array $order
- */
- private function resolveFakturowniaOid(int $orderId, array $order): string
- {
- $internalNumber = trim((string) ($order['internal_order_number'] ?? ''));
- if ($internalNumber !== '') {
- return $internalNumber;
- }
-
- return 'orderpro-' . $orderId;
- }
-
- /**
- * @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, Erli).
+ * Forwarder do `InvoiceSnapshotBuilder::extractBuyerTaxNumber()` — zachowany dla
+ * kompatybilnosci ze statycznym wolaniem z `InvoiceController`.
*
* @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 wysyłki',
- '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 $externalOid,
- 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 pominięte. 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 (użytkownik 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',
- 'oid' => $externalOid,
- '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'] ?? '');
- }
-
- $descriptionReference = $orderReference !== '' ? $orderReference : $externalOid;
- if ($descriptionReference !== '') {
- $invoice['additional_info_desc'] = 'Zamówienie: ' . $descriptionReference;
- }
-
- // department_id celowo pominięte — 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;
- }
-
- /**
- * @param array $sellerSnapshot
- * @param array|null $buyerSnapshot
- * @param list> $itemsSnapshot
- * @return array
- */
- private function buildDelegatedInvoiceData(
- int $orderId,
- int $configId,
- string $issueDate,
- string $saleDate,
- string $paymentDueDate,
- array $sellerSnapshot,
- ?array $buyerSnapshot,
- array $itemsSnapshot,
- float $totalNet,
- float $totalGross,
- string $externalOid,
- string $kind,
- ?int $createdBy
- ): array {
- return [
- 'order_id' => $orderId,
- 'config_id' => $configId,
- '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, '.', ''),
- 'external_oid' => $externalOid,
- 'kind' => $kind,
- 'created_by' => $createdBy,
- ];
- }
-
- /**
- * @param array $settings
- * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array}|null
- */
- private function findRemoteInvoiceByOid(array $settings, string $externalOid): ?array
- {
- try {
- return $this->fakturowniaApi->findInvoiceByOid($settings, $externalOid);
- } catch (Throwable) {
- return null;
- }
- }
-
- /**
- * @param array|null $local
- * @param array $pendingData
- * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote
- * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
- */
- private function attachRemoteInvoice(?array $local, array $pendingData, array $remote): array
- {
- $invoiceId = $local !== null
- ? (int) ($local['id'] ?? 0)
- : $this->invoices->insertDelegatedPending($pendingData);
-
- return $this->finalizeDelegatedInvoice($invoiceId, $remote);
- }
-
- /**
- * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote
- * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
- */
- private function finalizeDelegatedInvoice(int $invoiceId, array $remote): array
- {
- $externalId = trim((string) ($remote['id'] ?? ''));
- $externalNumber = trim((string) ($remote['number'] ?? ''));
- $externalPdfUrl = trim((string) ($remote['pdf_url'] ?? $remote['view_url'] ?? ''));
-
- if ($externalId === '' || $externalNumber === '') {
- throw new InvoiceIssueException('Fakturownia zwróciła niekompletną odpowiedź (brak id/number).');
- }
-
- $this->invoices->finalizeDelegatedExternal($invoiceId, [
- 'invoice_number' => $externalNumber,
- 'external_invoice_id' => $externalId,
- 'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null,
- ]);
-
- $invoice = $this->invoices->findById($invoiceId);
- $totalGross = is_array($invoice) ? (float) ($invoice['total_gross'] ?? 0) : 0.0;
-
- return [
- 'invoice_id' => $invoiceId,
- 'invoice_number' => $externalNumber,
- 'total_gross' => number_format($totalGross, 2, '.', ''),
- 'mode' => 'delegated',
- ];
- }
-
- /**
- * @param array|null $invoice
- */
- private function isIssuedDelegatedInvoice(?array $invoice): bool
- {
- if ($invoice === null) {
- return false;
- }
-
- return trim((string) ($invoice['external_invoice_id'] ?? '')) !== ''
- && trim((string) ($invoice['invoice_number'] ?? '')) !== ''
- && (string) ($invoice['external_status'] ?? 'issued') !== 'pending_external';
- }
-
- /**
- * @param array $invoice
- * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
- */
- private function resultFromLocalDelegatedInvoice(array $invoice): array
- {
- return [
- 'invoice_id' => (int) ($invoice['id'] ?? 0),
- 'invoice_number' => (string) ($invoice['invoice_number'] ?? ''),
- 'total_gross' => number_format((float) ($invoice['total_gross'] ?? 0), 2, '.', ''),
- 'mode' => 'delegated',
- ];
+ return InvoiceSnapshotBuilder::extractBuyerTaxNumber($order, $buyerAddress);
}
}
diff --git a/src/Modules/Accounting/InvoiceSnapshotBuilder.php b/src/Modules/Accounting/InvoiceSnapshotBuilder.php
new file mode 100644
index 0000000..0b5ed04
--- /dev/null
+++ b/src/Modules/Accounting/InvoiceSnapshotBuilder.php
@@ -0,0 +1,223 @@
+
+ */
+ public 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
+ */
+ public 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, Erli).
+ *
+ * @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}
+ */
+ public 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 wysyłki',
+ '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),
+ ];
+ }
+}
diff --git a/src/Modules/Accounting/LocalInvoiceIssuer.php b/src/Modules/Accounting/LocalInvoiceIssuer.php
new file mode 100644
index 0000000..81c55fe
--- /dev/null
+++ b/src/Modules/Accounting/LocalInvoiceIssuer.php
@@ -0,0 +1,66 @@
+ $config
+ * @param array $sellerSnapshot
+ * @param array|null $buyerSnapshot
+ * @param list> $itemsSnapshot
+ * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
+ */
+ public function issue(
+ 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',
+ ];
+ }
+}