This commit is contained in:
2026-05-20 15:33:33 +02:00
parent a3062edfc7
commit aeb6fb6df2
14 changed files with 1273 additions and 691 deletions

View File

@@ -4,6 +4,18 @@
**Ostatnia aktualizacja:** 2026-05-20 **Ostatnia aktualizacja:** 2026-05-20
## Aktywna praca ## 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`. 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`.
``` ```

View File

@@ -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`. - [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). - 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 ## Zmienione pliki
@@ -12,9 +16,19 @@
- `database/migrations/20260520_000119_seed_storage_cleanup_cron.sql` - `database/migrations/20260520_000119_seed_storage_cleanup_cron.sql`
- `.env.example` - `.env.example`
- `CLAUDE.md` - `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/architecture.md`
- `.paul/codebase/db_schema.md` - `.paul/codebase/db_schema.md`
- `.paul/codebase/quality_risks.md`
- `.paul/codebase/tech_changelog.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/PLAN.md`
- `.paul/plans/20260520-1128-storage-cleanup-cron/SUMMARY.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` - `.paul/STATE.md`

View File

@@ -66,7 +66,7 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.):
| Orders | `src/Modules/Orders/` | zamowienia, notatki, import, statusy | | Orders | `src/Modules/Orders/` | zamowienia, notatki, import, statusy |
| Statistics | `src/Modules/Statistics/` | raporty zamowien (slim Controller + `OrdersStatisticsFilters` + `OrdersStatisticsTableBuilder` + `OrdersStatisticsSummaryBuilder` + Repository) | | 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`). | | 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) | | 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) | | Printing | `src/Modules/Printing/` | API drukowania etykiet (klient Windows) |
| Email | `src/Modules/Email/` | SMTP, wysylka maili | | Email | `src/Modules/Email/` | SMTP, wysylka maili |

View File

@@ -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/Settings/AllegroOrderImportService.php` | 834 | duzy import service. |
| `src/Modules/Automation/AutomationService.php` | 818 | silnik regul — kandydat na pipeline-strategy. | | `src/Modules/Automation/AutomationService.php` | 818 | silnik regul — kandydat na pipeline-strategy. |
| `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | | | `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/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/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`. | | ~~`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`. |

View File

@@ -2,6 +2,42 @@
Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze. 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/`) ## 2026-05-20 — Cron `storage_cleanup` (retencja 30 dni dla katalogu `storage/`)
### Co ### Co

View File

@@ -1,7 +1,20 @@
# Tooling Status # Tooling Status
**Timestamp:** 2026-05-19 **Timestamp:** 2026-05-20 (last post-apply: refactor InvoiceService)
**Tryb skanu:** full (`/paul:map-codebase`) **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 ## Status narzedzi

View File

@@ -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
---
<objective>
## 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).
</objective>
<context>
## 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
</context>
<clarifications>
- 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`.
</clarifications>
<impact_scan>
## 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.
</impact_scan>
<skills>
Brak SPECIAL-FLOWS.md w `.paul/` — sekcja pominieta.
</skills>
<acceptance_criteria>
## 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 `~~~~ -> <N>` ze wskazaniem SUMMARY
And `.paul/codebase/tech_changelog.md` zawiera wpis z data 2026-05-20 i krotkim opisem
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Wyodrebnij InvoiceMetadataResolver (resolvery dat i referencji)</name>
<files>src/Modules/Accounting/InvoiceMetadataResolver.php</files>
<action>
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.
</action>
<verify>`php -l src/Modules/Accounting/InvoiceMetadataResolver.php` -> No syntax errors; `wc -l` ~100-120.</verify>
<done>AC-2, AC-3.</done>
</task>
<task type="auto">
<name>Task 2: Wyodrebnij InvoiceSnapshotBuilder (seller/buyer/items + ekstrakcja NIP)</name>
<files>src/Modules/Accounting/InvoiceSnapshotBuilder.php</files>
<action>
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.
</action>
<verify>`php -l src/Modules/Accounting/InvoiceSnapshotBuilder.php` -> No syntax errors; `wc -l` ~220-240.</verify>
<done>AC-2, AC-3.</done>
</task>
<task type="auto">
<name>Task 3: Wyodrebnij FakturowniaInvoicePayloadBuilder</name>
<files>src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php</files>
<action>
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.
</action>
<verify>`php -l src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php` -> No syntax errors; `wc -l` ~80-100; grep -c "CELOWO" >= 1.</verify>
<done>AC-2, AC-3.</done>
</task>
<task type="auto">
<name>Task 4: Wyodrebnij DelegatedInvoiceIssuer (idempotentna sciezka Fakturowni)</name>
<files>src/Modules/Accounting/DelegatedInvoiceIssuer.php</files>
<action>
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...").
</action>
<verify>`php -l src/Modules/Accounting/DelegatedInvoiceIssuer.php` -> No syntax errors; `wc -l` ~180-200; grep "InvoiceIssueException" >= 4 (4 miejsca rzucania).</verify>
<done>AC-2, AC-3, AC-4.</done>
</task>
<task type="auto">
<name>Task 5: Przepisz InvoiceService na slim fasade</name>
<files>src/Modules/Accounting/InvoiceService.php</files>
<action>
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}`).
</action>
<verify>`php -l src/Modules/Accounting/InvoiceService.php` -> No syntax errors; `wc -l` <= 160; grep "public function" -> 2 (issue + statyk); grep "private function" -> 1 (issueLocal).</verify>
<done>AC-1, AC-3.</done>
</task>
<task type="auto">
<name>Task 6: Weryfikacja integracyjna (controller + module + test + smoke)</name>
<files>src/Modules/Accounting/InvoiceController.php, src/Modules/Accounting/AccountingModule.php, tests/Unit/FakturowniaInvoiceIdempotencyTest.php</files>
<action>
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.
</action>
<verify>5/5 plikow `php -l` -> No syntax errors; konsumenci `extractBuyerTaxNumber` i `new InvoiceService(...)` niezmienieni.</verify>
<done>AC-1, AC-4.</done>
</task>
<task type="auto">
<name>Task 7: Aktualizacja dokumentacji `.paul/codebase/`</name>
<files>.paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md</files>
<action>
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~~ -> <N> | ✅ 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.
</action>
<verify>`grep -c "InvoiceMetadataResolver" .paul/codebase/architecture.md` >= 1; `grep "20260520-1500-refactor-invoice-service" .paul/codebase/quality_risks.md` >= 1.</verify>
<done>AC-5.</done>
</task>
</tasks>
<boundaries>
## 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.
</boundaries>
<verification>
- [ ] `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`.
</verification>
<success_criteria>
- [ ] 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.
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`
</output>

View File

@@ -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.

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Modules\Settings\FakturowniaApiClient;
use App\Modules\Settings\FakturowniaIntegrationRepository;
use Throwable;
final class DelegatedInvoiceIssuer
{
public function __construct(
private readonly InvoiceRepository $invoices,
private readonly FakturowniaIntegrationRepository $fakturownia,
private readonly FakturowniaApiClient $fakturowniaApi,
private readonly FakturowniaInvoicePayloadBuilder $payloadBuilder
) {
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
* @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<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array<string, mixed>
*/
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<string, mixed> $settings
* @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array<string, mixed>}|null
*/
private function findRemoteInvoiceByOid(array $settings, string $externalOid): ?array
{
try {
return $this->fakturowniaApi->findInvoiceByOid($settings, $externalOid);
} catch (Throwable) {
return null;
}
}
/**
* @param array<string, mixed>|null $local
* @param array<string, mixed> $pendingData
* @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array<string, mixed>} $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<string, mixed>} $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<string, mixed>|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<string, mixed> $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',
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
final class FakturowniaInvoicePayloadBuilder
{
/**
* Build Fakturownia API payload (https://app.fakturownia.pl/api).
*
* @param array<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array<string, mixed>
*/
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;
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
final class InvoiceMetadataResolver
{
public function resolveIssueDate(string $override): string
{
$override = trim($override);
if ($override !== '' && strtotime($override) !== false) {
return date('Y-m-d H:i:s', (int) strtotime($override));
}
return date('Y-m-d H:i:s');
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $payments
*/
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<string, mixed> $config
* @param array<string, mixed> $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<string, mixed> $order
*/
public function resolveFakturowniaOid(int $orderId, array $order): string
{
$internalNumber = trim((string) ($order['internal_order_number'] ?? ''));
if ($internalNumber !== '') {
return $internalNumber;
}
return 'orderpro-' . $orderId;
}
}

View File

@@ -8,18 +8,31 @@ use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\FakturowniaApiClient; use App\Modules\Settings\FakturowniaApiClient;
use App\Modules\Settings\FakturowniaIntegrationRepository; use App\Modules\Settings\FakturowniaIntegrationRepository;
use App\Modules\Settings\InvoiceConfigRepository; use App\Modules\Settings\InvoiceConfigRepository;
use Throwable;
final class InvoiceService final class InvoiceService
{ {
private readonly InvoiceMetadataResolver $metadata;
private readonly InvoiceSnapshotBuilder $snapshots;
private readonly LocalInvoiceIssuer $local;
private readonly DelegatedInvoiceIssuer $delegated;
public function __construct( public function __construct(
private readonly InvoiceRepository $invoices, InvoiceRepository $invoices,
private readonly InvoiceConfigRepository $invoiceConfigs, private readonly InvoiceConfigRepository $invoiceConfigs,
private readonly CompanySettingsRepository $companySettings, CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders, private readonly OrdersRepository $orders,
private readonly FakturowniaIntegrationRepository $fakturownia, FakturowniaIntegrationRepository $fakturownia,
private readonly FakturowniaApiClient $fakturowniaApi 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'] : []; $addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : []; $payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
$issueDate = $this->resolveIssueDate((string) ($params['issue_date_override'] ?? '')); $issueDate = $this->metadata->resolveIssueDate((string) ($params['issue_date_override'] ?? ''));
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate); $saleDate = $this->metadata->resolveSaleDate($config, $order, $payments, $issueDate);
$paymentDueDate = $this->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7)); $paymentDueDate = $this->metadata->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7));
$orderReference = $this->resolveOrderReference($config, $order); $orderReference = $this->metadata->resolveOrderReference($config, $order);
$externalOid = $this->resolveFakturowniaOid($orderId, $order); $externalOid = $this->metadata->resolveFakturowniaOid($orderId, $order);
$sellerSnapshot = $this->buildSellerSnapshot(); $sellerSnapshot = $this->snapshots->buildSellerSnapshot();
$buyerSnapshot = $this->buildBuyerSnapshot($order, $addresses, $params); $buyerSnapshot = $this->snapshots->buildBuyerSnapshot($order, $addresses, $params);
['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->buildItemsSnapshot($items, $order); ['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->snapshots->buildItemsSnapshot($items, $order);
$kind = trim((string) ($config['default_kind'] ?? 'vat')) ?: 'vat'; $kind = trim((string) ($config['default_kind'] ?? 'vat')) ?: 'vat';
$isDelegated = (int) ($config['is_delegated'] ?? 0) === 1; $createdBy = $params['created_by'] ?? null;
if ($isDelegated) { if ((int) ($config['is_delegated'] ?? 0) === 1) {
return $this->issueDelegated( return $this->delegated->issue(
$orderId, $orderId, $configId, $config,
$configId, $issueDate, $saleDate, $paymentDueDate,
$config, $sellerSnapshot, $buyerSnapshot, $itemsSnapshot,
$issueDate, $totalNet, $totalGross,
$saleDate, $externalOid, $orderReference, $kind, $createdBy
$paymentDueDate,
$sellerSnapshot,
$buyerSnapshot,
$itemsSnapshot,
$totalNet,
$totalGross,
$externalOid,
$orderReference,
$kind,
$params['created_by'] ?? null
); );
} }
return $this->issueLocal( return $this->local->issue(
$orderId, $orderId, $configId, $config,
$configId, $issueDate, $saleDate, $paymentDueDate,
$config, $sellerSnapshot, $buyerSnapshot, $itemsSnapshot,
$issueDate, $totalNet, $totalGross,
$saleDate, $orderReference, $kind, $createdBy
$paymentDueDate,
$sellerSnapshot,
$buyerSnapshot,
$itemsSnapshot,
$totalNet,
$totalGross,
$orderReference,
$kind,
$params['created_by'] ?? null
); );
} }
/** /**
* @param array<string, mixed> $config * Forwarder do `InvoiceSnapshotBuilder::extractBuyerTaxNumber()` — zachowany dla
* @param array<string, mixed> $sellerSnapshot * kompatybilnosci ze statycznym wolaniem z `InvoiceController`.
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
*/
private function issueLocal(
int $orderId,
int $configId,
array $config,
string $issueDate,
string $saleDate,
string $paymentDueDate,
array $sellerSnapshot,
?array $buyerSnapshot,
array $itemsSnapshot,
float $totalNet,
float $totalGross,
string $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<string, mixed> $config
* @param array<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
*/
private function issueDelegated(
int $orderId,
int $configId,
array $config,
string $issueDate,
string $saleDate,
string $paymentDueDate,
array $sellerSnapshot,
?array $buyerSnapshot,
array $itemsSnapshot,
float $totalNet,
float $totalGross,
string $orderReference,
string $kind,
?int $createdBy
): array {
$integrationId = (int) ($config['integration_id'] ?? 0);
if ($integrationId <= 0) {
throw new InvoiceIssueException('Konfiguracja delegowana nie wskazuje konta Fakturowni.');
}
$account = $this->fakturownia->findByIntegrationId($integrationId);
if ($account === null) {
throw new InvoiceIssueException('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<string, mixed> $config
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $payments
*/
private function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string
{
$source = (string) ($config['sale_date_source'] ?? 'issue_date');
if ($source === 'order_date') {
$ordered = trim((string) ($order['external_created_at'] ?? $order['ordered_at'] ?? ''));
if ($ordered !== '') {
$ts = strtotime($ordered);
return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate;
}
}
if ($source === 'payment_date' && $payments !== []) {
$payment = $payments[0] ?? [];
$payDate = trim((string) ($payment['payment_date'] ?? ''));
if ($payDate !== '') {
$ts = strtotime($payDate);
return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate;
}
}
return $issueDate;
}
private function resolvePaymentDueDate(string $issueDate, int $paymentToDays): string
{
$ts = strtotime($issueDate);
if ($ts === false) {
$ts = time();
}
return date('Y-m-d 00:00:00', $ts + max(0, $paymentToDays) * 86400);
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
*/
private function resolveOrderReference(array $config, array $order): string
{
$ref = (string) ($config['order_reference'] ?? 'none');
if ($ref === 'orderpro') {
return (string) ($order['internal_order_number'] ?? '');
}
if ($ref === 'integration') {
return (string) ($order['external_order_number'] ?? $order['external_order_id'] ?? '');
}
return '';
}
/**
* @param array<string, mixed> $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<string, mixed>
*/
private function buildSellerSnapshot(): array
{
$seller = $this->companySettings->getSettings();
return [
'company_name' => $seller['company_name'] ?? '',
'tax_number' => $seller['tax_number'] ?? '',
'street' => $seller['street'] ?? '',
'city' => $seller['city'] ?? '',
'postal_code' => $seller['postal_code'] ?? '',
'phone' => $seller['phone'] ?? '',
'email' => $seller['email'] ?? '',
'bank_account' => $seller['bank_account'] ?? '',
'bdo_number' => $seller['bdo_number'] ?? '',
'regon' => $seller['regon'] ?? '',
'court_register' => $seller['court_register'] ?? '',
];
}
/**
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $addresses
* @param array<string, mixed> $params
* @return array<string, mixed>|null
*/
private function buildBuyerSnapshot(array $order, array $addresses, array $params): ?array
{
$byType = [];
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type !== '' && !isset($byType[$type])) {
$byType[$type] = $addr;
}
}
$buyerAddress = $byType['invoice'] ?? $byType['customer'] ?? null;
$autoTaxNumber = self::extractBuyerTaxNumber($order, $buyerAddress);
$manualTax = trim((string) ($params['buyer_tax_number'] ?? ''));
$manualName = trim((string) ($params['buyer_name'] ?? ''));
$manualCompany = trim((string) ($params['buyer_company_name'] ?? ''));
$manualStreet = trim((string) ($params['buyer_street'] ?? ''));
$manualCity = trim((string) ($params['buyer_city'] ?? ''));
$manualPostal = trim((string) ($params['buyer_postal_code'] ?? ''));
$manualEmail = trim((string) ($params['buyer_email'] ?? ''));
$name = $manualName !== ''
? $manualName
: trim((string) ($buyerAddress['name'] ?? ''));
$company = $manualCompany !== ''
? $manualCompany
: trim((string) ($buyerAddress['company_name'] ?? ''));
$street = $manualStreet !== ''
? $manualStreet
: trim(((string) ($buyerAddress['street_name'] ?? '')) . ' ' . ((string) ($buyerAddress['street_number'] ?? '')));
$city = $manualCity !== ''
? $manualCity
: trim((string) ($buyerAddress['city'] ?? ''));
$postal = $manualPostal !== ''
? $manualPostal
: trim((string) ($buyerAddress['zip_code'] ?? ''));
$email = $manualEmail !== ''
? $manualEmail
: trim((string) ($buyerAddress['email'] ?? $order['buyer_email'] ?? ''));
$taxNumber = $manualTax !== '' ? $manualTax : $autoTaxNumber;
if ($name === '' && $company === '' && $taxNumber === '' && $street === '') {
return null;
}
return [
'name' => $name,
'company_name' => $company,
'tax_number' => $taxNumber,
'street' => $street,
'city' => $city,
'postal_code' => $postal,
'phone' => trim((string) ($buyerAddress['phone'] ?? '')),
'email' => $email,
];
}
/**
* Extract NIP from various payload locations (Allegro, shopPRO, Erli).
* *
* @param array<string, mixed> $order * @param array<string, mixed> $order
* @param array<string, mixed>|null $buyerAddress * @param array<string, mixed>|null $buyerAddress
*/ */
public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string
{ {
if ($buyerAddress !== null) { return InvoiceSnapshotBuilder::extractBuyerTaxNumber($order, $buyerAddress);
$candidate = trim((string) ($buyerAddress['company_tax_number'] ?? ''));
if ($candidate !== '') {
return $candidate;
}
}
$payload = $order['payload_json'] ?? null;
if (is_string($payload) && $payload !== '') {
$decoded = json_decode($payload, true);
if (is_array($decoded)) {
foreach (self::taxNumberPaths() as $path) {
$value = self::digValue($decoded, $path);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
}
return '';
}
/**
* @return list<list<string>>
*/
private static function taxNumberPaths(): array
{
return [
['invoice', 'address', 'taxId'],
['invoice', 'taxId'],
['invoice', 'nip'],
['buyer', 'tax_number'],
['buyer', 'nip'],
['client', 'nip'],
['client', 'tax_number'],
['nip'],
['tax_number'],
];
}
/**
* @param array<string, mixed> $arr
* @param list<string> $path
*/
private static function digValue(array $arr, array $path): mixed
{
$cur = $arr;
foreach ($path as $key) {
if (!is_array($cur) || !array_key_exists($key, $cur)) {
return null;
}
$cur = $cur[$key];
}
return $cur;
}
/**
* @param list<array<string, mixed>> $items
* @param array<string, mixed> $order
* @return array{items: list<array<string, mixed>>, total_gross: float, total_net: float}
*/
private function buildItemsSnapshot(array $items, array $order): array
{
$itemsSnapshot = [];
$totalGross = 0.0;
$totalNet = 0.0;
foreach ($items as $item) {
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : (float) ($item['price_gross'] ?? 0);
$vat = (float) ($item['vat'] ?? 23);
$lineGross = $qty * $price;
$lineNet = $vat > 0 ? round($lineGross / (1 + $vat / 100), 2) : $lineGross;
$totalGross += $lineGross;
$totalNet += $lineNet;
$itemsSnapshot[] = [
'name' => $item['original_name'] ?? $item['name'] ?? '',
'quantity' => $qty,
'price_gross' => $price,
'price_net' => $vat > 0 ? round($price / (1 + $vat / 100), 2) : $price,
'vat' => $vat,
'total_gross' => round($lineGross, 2),
'total_net' => round($lineNet, 2),
'sku' => $item['sku'] ?? '',
'ean' => $item['ean'] ?? '',
];
}
$deliveryPrice = (float) ($order['delivery_price'] ?? 0);
if ($deliveryPrice > 0) {
$deliveryVat = 23.0;
$deliveryNet = round($deliveryPrice / (1 + $deliveryVat / 100), 2);
$totalGross += $deliveryPrice;
$totalNet += $deliveryNet;
$itemsSnapshot[] = [
'name' => 'Koszt 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<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array<string, mixed>
*/
private function buildFakturowniaPayload(
string $kind,
string $issueDate,
string $saleDate,
string $paymentDueDate,
array $sellerSnapshot,
?array $buyerSnapshot,
array $itemsSnapshot,
string $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<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array<string, mixed>
*/
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<string, mixed> $settings
* @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array<string, mixed>}|null
*/
private function findRemoteInvoiceByOid(array $settings, string $externalOid): ?array
{
try {
return $this->fakturowniaApi->findInvoiceByOid($settings, $externalOid);
} catch (Throwable) {
return null;
}
}
/**
* @param array<string, mixed>|null $local
* @param array<string, mixed> $pendingData
* @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array<string, mixed>} $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<string, mixed>} $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<string, mixed>|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<string, mixed> $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',
];
} }
} }

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Modules\Settings\CompanySettingsRepository;
final class InvoiceSnapshotBuilder
{
public function __construct(
private readonly CompanySettingsRepository $companySettings
) {
}
/**
* @return array<string, mixed>
*/
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<string, mixed> $order
* @param list<array<string, mixed>> $addresses
* @param array<string, mixed> $params
* @return array<string, mixed>|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<string, mixed> $order
* @param array<string, mixed>|null $buyerAddress
*/
public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string
{
if ($buyerAddress !== null) {
$candidate = trim((string) ($buyerAddress['company_tax_number'] ?? ''));
if ($candidate !== '') {
return $candidate;
}
}
$payload = $order['payload_json'] ?? null;
if (is_string($payload) && $payload !== '') {
$decoded = json_decode($payload, true);
if (is_array($decoded)) {
foreach (self::taxNumberPaths() as $path) {
$value = self::digValue($decoded, $path);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
}
return '';
}
/**
* @return list<list<string>>
*/
private static function taxNumberPaths(): array
{
return [
['invoice', 'address', 'taxId'],
['invoice', 'taxId'],
['invoice', 'nip'],
['buyer', 'tax_number'],
['buyer', 'nip'],
['client', 'nip'],
['client', 'tax_number'],
['nip'],
['tax_number'],
];
}
/**
* @param array<string, mixed> $arr
* @param list<string> $path
*/
private static function digValue(array $arr, array $path): mixed
{
$cur = $arr;
foreach ($path as $key) {
if (!is_array($cur) || !array_key_exists($key, $cur)) {
return null;
}
$cur = $cur[$key];
}
return $cur;
}
/**
* @param list<array<string, mixed>> $items
* @param array<string, mixed> $order
* @return array{items: list<array<string, mixed>>, total_gross: float, total_net: float}
*/
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),
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
final class LocalInvoiceIssuer
{
public function __construct(
private readonly InvoiceRepository $invoices
) {
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $sellerSnapshot
* @param array<string, mixed>|null $buyerSnapshot
* @param list<array<string, mixed>> $itemsSnapshot
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
*/
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',
];
}
}