diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index ef5e681..f532489 100644 --- a/.paul/PROJECT.md +++ b/.paul/PROJECT.md @@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów | Attribute | Value | |-----------|-------| | Version | 3.9.0-dev | -| Status | v3.9 Stabilizacja i splata dlugu technicznego in progress - Phase 135 accounting net correctness complete; Phase 136 ready to plan | -| Last Updated | 2026-05-16 (Phase 135 closed) | +| Status | v3.9 Stabilizacja i splata dlugu technicznego in progress - Phase 136 Fakturownia invoice idempotency complete; Phase 137 ready to plan | +| Last Updated | 2026-05-17 (Phase 136 closed) | ## Requirements @@ -135,6 +135,7 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów - [x] Parytet Erli w powierzchniach wspolnych: filtr zrodla zamowien, kanaly statystyk dziennych/podsumowania, warunek integracji automatyzacji, menu integracji i etykiety `zrodlo` uzywaja wspolnego rejestru zrodel — Phase 133 - [x] Backlog Reality Check: `.paul/codebase/todo.md` i `.paul/codebase/concerns.md` sklasyfikowane przeciw aktualnemu kodowi/docs, z dowodami w `BACKLOG-AUDIT.md` i routingiem do faz 135-142 — Phase 134 - [x] Accounting Net Correctness: nowe paragony zapisuja VAT-aware `receipts.total_net`, a statystyki dzienne preferuja source-level net, potem `order_items` VAT fallback, z gross `/1.23` tylko jako legacy fallback — Phase 135 +- [x] Fakturownia Invoice Idempotency: delegowane faktury uzywaja stabilnego `oid=orders.internal_order_number`, lookup-first `GET /invoices.json?oid=...`, lokalnego stanu `pending_external`/`failed_retryable` i auto-attach po timeoutach — Phase 136 - [x] Integracja polkurier.pl (fundament): pojedyncza globalna konfiguracja w `/settings/integrations/polkurier`, szyfrowany Token API + login, karta w hubie integracji obok Apaczki i realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na zywym koncie operatora; `ShipmentProviderRegistry` netkniety — `PolkurierShipmentService/TrackingService` w kolejnych fazach — Phase 127 - [x] polkurier ShipmentService + TrackingService + UI prepare panel: pelen kontrakt API (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers), `PolkurierShipmentService` implementujacy `ShipmentProviderInterface` z normalizacja shipmenttype (lowercase) i splitem ulicy na street/housenumber/flatnumber, `PolkurierTrackingService` mapujacy statusy O/P/A/WP/D/Z/W na znormalizowane, panel "polkurier" w `prepare.php` z dynamiczna lista uslug z `available_carriers`, seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami z PDF v1.11; live test na #114/#115 zakonczony sukcesem po 4 iteracjach (ReferenceError → uppercase shipmenttype → orderno parsing → A4/A6); rozmiar etykiety sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — Phase 128 - [x] Order User Notes module (Phase 129): pelen CRUD notatek autorskich operatora per zamowienie. Reuse `order_notes` przez nowy `note_type='user'` z `user_id` (FK→users SET NULL) + `author_name` (snapshot) + indeks `idx_order_notes_type_order`. `OrderNotesService` z autoryzacja DB-level (`WHERE user_id = :user_id`, rowCount=0 ⇒ 403). Sekcja `#notes` w "Wiadomosci i zalaczniki" w `/orders/{id}` z inline edit form + delete przez `OrderProAlerts.confirm`. Badge `[N]` (indigo neutralny) przy nr zamowienia na `/orders/list` (subquery `user_notes_count` w paginate). Brak admin override (brak systemu rol w aplikacji) — edit/delete tylko dla autora — Phase 129 @@ -147,7 +148,7 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów ### Active (In Progress) -- [ ] v3.9 Stabilizacja i splata dlugu technicznego — Phase 136 Fakturownia Invoice Idempotency ready to plan after Phase 135. +- [ ] v3.9 Stabilizacja i splata dlugu technicznego — Phase 137 Delivery Status Backlog Verification ready to plan after Phase 136. ### Planned (Next) @@ -241,7 +242,7 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API | Legacy aliasy starych endpointow `/settings/accounting/save\|toggle\|delete` jako duplicate routes | Brak inwentaryzacji zewnetrznych referencji/bookmarkow - zero kosztu utrzymania, pelna wsteczna kompatybilnosc | 2026-05-10 | Active | | `OrderProAlerts.confirm` to options-object API (`{title, message, onConfirm, danger, confirmLabel}`), nie pozycyjne argumenty | Pozycyjne wywolanie cicho fail'uje - callback ginie. Bug znaleziony w Phase 114-01 podczas smoke testu user. Pattern dla wszystkich przyszlych confirm dialogow | 2026-05-10 | Active | | Globalny `confirm-delete.js` z `data-confirm-bound='1'` idempotent guard | Stare widoki maja inline scripts robiace to samo - guard zapobiega podwojnemu bindowi gdy modul globalny widzi juz-bound buttony. Mozna stopniowo migrowac stare widoki | 2026-05-10 | Active | -| Wystawianie faktury delegowanej do Fakturowni: POST PRZED INSERT lokalnym | Brak orphan rows w `invoices` gdy API padnie. On success zapis `external_invoice_id`/`external_pdf_url`/`invoice_number` z odpowiedzi Fakturowni. Trade-off: brak idempotencji przy double-POST -> INVOICE-IDEMP-115 w todo.md | 2026-05-10 | Active | +| Wystawianie faktury delegowanej do Fakturowni: pending local row PRZED POST z lookup-first po `oid` | Phase 136 zastapil stare POST-przed-INSERT: delegacja zapisuje `pending_external`, uzywa `oid=orders.internal_order_number`, sprawdza `GET /invoices.json?oid=...` przed POST i auto-podpina fakture po timeoutach. | 2026-05-17 | Active | | NIP lookup przez MF Biala Liste (publiczne API) zamiast Fakturowni | Fakturownia API NIE MA endpointu GUS (sprawdzone w dokumentacji 2026-05-10). MF Biala Lista jest publiczna (bez rejestracji/klucza), zwraca nazwa+adres+REGON. Klient `MfWhitelistApiClient` w `src/Core/Http/` dostepny dla innych modulow | 2026-05-10 | Active | | Fakturownia invoice payload: NIE wysylamy `seller_*` ani `department_id` | Konta z podwyzszonym security interpretuja roznice w seller_bank_account jako proba "utworz nowy dzial" -> HTTP 422. Fakturownia uzywa danych konta jako sprzedawca. Lokalny snapshot w `invoices.seller_data_json` zachowany dla audytu | 2026-05-10 | Active | | PHP 8.5: zakaz `curl_close()` w nowym kodzie | Deprecated od 8.5 (no-op od 8.0). Wycieka HTML `
Deprecated...` przed JSON response -> "json is not valid" w fetch().json(). Pattern dla wszystkich httpGet/httpPost helperow w `src/Core/Http/` i `src/Modules/Settings/*ApiClient` | 2026-05-10 | Active | @@ -313,6 +314,6 @@ Quick Reference: --- *PROJECT.md — Updated when requirements or context change* -*Last updated: 2026-05-16 after Phase 135 (Accounting Net Correctness) closure* +*Last updated: 2026-05-17 after Phase 136 (Fakturownia Invoice Idempotency) closure* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index aab642d..13ea0af 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -12,13 +12,13 @@ Milestone porzadkujacy zbudowany z `.paul/codebase/todo.md` i `.paul/codebase/co Rule for every phase/plan: przed implementacja sprawdzic w kodzie i dokumentacji, czy wpis nadal jest aktualny i czy nie zostal juz wdrozony; nastepnie przedstawic krotki plan operatorowi i zapytac o potwierdzenie. Dopiero po akceptacji wolno wprowadzac zmiany i uruchamiac testy. Jezeli wpis jest nieaktualny albo juz zrealizowany, faza/planu ma zamknac go dokumentacyjnie bez niepotrzebnej zmiany kodu. -Progress: 2 of 9 phases complete (22%). +Progress: 3 of 9 phases complete (33%). | Phase | Name | Plans | Status | |-------|------|-------|--------| | 134 | Backlog Reality Check | 1/1 | Complete (2026-05-16; documentation-only audit, Sonar CLI gap documented) | | 135 | Accounting Net Correctness | 1/1 | Complete (2026-05-16; VAT-aware receipt/stat net, PHPUnit/Sonar env gaps documented) | -| 136 | Fakturownia Invoice Idempotency | TBD | Not started | +| 136 | Fakturownia Invoice Idempotency | 1/1 | Complete (2026-05-17; Fakturownia oid idempotency, migration/PHPUnit/Sonar env gaps documented) | | 137 | Delivery Status Backlog Verification | TBD | Not started | | 138 | Security and Legacy Hardening | TBD | Not started | | 139 | Sonar Critical/Major Cleanup | TBD | Not started | @@ -39,7 +39,7 @@ Plans: 135-01 (complete; `.paul/phases/135-accounting-net-correctness/135-01-SUM ### Phase 136: Fakturownia Invoice Idempotency Focus: Domknac `INVOICE-IDEMP-115`: zabezpieczyc delegowane wystawianie faktur przed podwojnym POST do Fakturowni po timeoutach lub utracie odpowiedzi, z weryfikacja mozliwosci `Idempotency-Key` albo deduplikacji po referencji. -Plans: TBD (defined during $paul-plan) +Plans: 136-01 (complete; `.paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md`) ### Phase 137: Delivery Status Backlog Verification @@ -155,7 +155,6 @@ Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania): - polkurier TrackingService + `delivery_status_mappings` (provider='polkurier') - polkurier paczkomaty (`InpostParcelMachines` / `PocztexPostOffices` / `Kurier48PostOffices` z SDK polkuriera) - Eksport XLSX listy wystawionych faktur (analogicznie do paragonow) -- Idempotencja podwojnego POST do Fakturowni (INVOICE-IDEMP-115) - Event automatyzacji `invoice.created` (jezeli operator chce wysylac faktury mailem) - Automatyzacje SMS / odbior odpowiedzi SMS po aktywacji HostedSMS - SMSPLANET conversation mode: wybor nadpis/numer 2WAY, odbior odpowiedzi, historia SMS w zamowieniu i notification center - Phase 121 planning @@ -634,4 +633,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md` --- *Roadmap created: 2026-03-12* -*Last updated: 2026-05-16 - Phase 135 complete; Phase 136 ready to plan* +*Last updated: 2026-05-17 - Phase 136 closed; Phase 137 ready to plan* diff --git a/.paul/STATE.md b/.paul/STATE.md index af9b35c..5b5afca 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -2,22 +2,22 @@ ## Project Reference -See: .paul/PROJECT.md (updated 2026-05-16) +See: .paul/PROJECT.md (updated 2026-05-17) **Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami. -**Current focus:** v3.9 Stabilizacja i splata dlugu technicznego; Phase 136 Fakturownia Invoice Idempotency ready to plan. +**Current focus:** v3.9 Stabilizacja i splata dlugu technicznego; Phase 136 complete, Phase 137 Delivery Status Backlog Verification ready to plan. ## Current Position Milestone: v3.9 Stabilizacja i splata dlugu technicznego -Phase: 136 of 142 (Fakturownia Invoice Idempotency) - Ready to plan +Phase: 137 of 142 (Delivery Status Backlog Verification) - Ready to plan Plan: Not started Status: Ready for next PLAN -Last activity: 2026-05-16 21:51 - Phase 135 complete, transitioned to Phase 136 +Last activity: 2026-05-17 17:36 - Phase 136 complete, transitioned to Phase 137 Progress: -- Milestone v3.9: [##--------] 22% (2 of 9 phases complete) -- Phase 136: [----------] 0% (not started) +- Milestone v3.9: [###-------] 33% (3 of 9 phases complete) +- Phase 137: [----------] 0% (not started) ## Loop Position @@ -29,18 +29,18 @@ PLAN -> APPLY -> UNIFY ## Session Continuity -Last session: 2026-05-16 21:51 -Stopped at: Phase 135 complete, ready to plan Phase 136 -Next action: $paul-plan for Phase 136 -Resume file: .paul/ROADMAP.md +Last session: 2026-05-17 17:36 +Stopped at: Phase 136 complete +Next action: Run $paul-plan for Phase 137 (Delivery Status Backlog Verification) +Resume file: .paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md ## Pending parallel work - None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1). ## Git State -Last phase commit: feat(135): accounting net correctness -Previous: 53f01c3 feat(134): backlog reality check +Last phase commit: HEAD feat(136): fakturownia invoice idempotency +Previous: feat(135): accounting net correctness Branch: main ### Skill Audit (Phase 129) @@ -85,6 +85,12 @@ Branch: main |----------|---------|-------| | `sonar-scanner` | gap documented | Attempted after APPLY with `sonar-scanner --version`; CLI is not available in PATH. | +### Skill Audit (Phase 136) + +| Expected | Invoked | Notes | +|----------|---------|-------| +| `sonar-scanner` | gap documented | Attempted after APPLY with `sonar-scanner --version`; CLI is not available in PATH. | + ## Accumulated Context ### Recent Decisions @@ -95,12 +101,14 @@ Branch: main - Phase 135 applies only to new receipts; historical receipt `total_net` rows are not backfilled by operator decision. - Phase 135 delivery net fallback uses 23% VAT when no source-level delivery VAT exists. - Phase 139 must refresh Sonar before cleanup because the current concern counts are a stale baseline. +- Phase 136 resolved `INVOICE-IDEMP-115`: Fakturownia delegated invoices use `orders.internal_order_number` as stable `oid`; retry flow is lookup-first by `GET /invoices.json?oid=...`, persists `pending_external`/`failed_retryable` state, and auto-attaches remote invoices found after timeout. ### Blockers / Concerns - Phase 134: `sonar-scanner` is still unavailable in PATH. - Phase 135: `vendor/bin/phpunit` and `sonar-scanner` are unavailable in PATH/checkout; syntax checks and ad-hoc SQLite/runtime smoke passed. -- Phase 136: Fakturownia idempotency strategy needs API/operator confirmation before code changes. +- Phase 136: Fakturownia idempotency strategy implemented and UNIFY complete; runtime migration still needs local MySQL online. +- Phase 136 APPLY: `php bin/migrate.php` could not run because local MySQL refused connection; `vendor/bin/phpunit` is missing; `sonar-scanner` is unavailable in PATH. PHP lint, documentation grep, git diff check and ad-hoc SQLite repository smoke passed. - Phase 140: deferred indexes should be applied only after operator confirms dataset size/prod timing. ### Deferred Issues @@ -120,6 +128,7 @@ Branch: main - Phase 121 transition note: git commit was not created during UNIFY because the worktree contains unrelated Phase 118/local dirty files; prepare a scoped commit manually. - Phase 122 follow-up: manually verify settings save/reload and real SMSPLANET test/order sends with non-empty and empty footer; manually trigger over-limit final body rejection in UI. - Phase 123 follow-up: wystaw nowy paragon i potwierdz `items_json` zawiera `vat` per pozycja; eksport XLSX z paragonem multi-rate (np. mix 23% + 8%) — sprawdz osobne wiersze; eksport "wybrane paragony" zachowuje breakdown. +- Phase 136 follow-up: uruchom `php bin/migrate.php` gdy lokalny MySQL/XAMPP jest online; migracja `20260517_000118_add_invoice_external_idempotency_state.sql` dodaje stan idempotencji delegowanych faktur. - Phase 124 follow-up: `php bin/migrate.php` (XAMPP MySQL online) — utworzy `sms_templates`. Operator nastepnie tworzy szablony manualnie z `/settings/sms-templates`. - Phase 124 follow-up: real smoke wysylki SMS z szablonu (zamowienie z paczka + skonfigurowana stopka SMSPLANET) — sprawdzic ze `sms_messages.body` ma stopke raz, finalna tresc <= 918 znakow. - Phase 124 follow-up: regresja Email — wyslij e-mail z istniejacym szablonem aby potwierdzic ze refaktor `Email\VariableResolver` na fasade nie zlamal `EmailSendingService`. @@ -166,4 +175,4 @@ Branch: main ## Skill Requirements -- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128, Phase 129, Phase 130, Phase 131, Phase 132, Phase 133, Phase 134 and Phase 135 gaps documented because CLI was not available in PATH. +- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128, Phase 129, Phase 130, Phase 131, Phase 132, Phase 133, Phase 134, Phase 135 and Phase 136 gaps documented because CLI was not available in PATH. diff --git a/.paul/changelog/2026-05-17.md b/.paul/changelog/2026-05-17.md new file mode 100644 index 0000000..47bdcca --- /dev/null +++ b/.paul/changelog/2026-05-17.md @@ -0,0 +1,28 @@ +# 2026-05-17 + +## Co zrobiono + +- [Phase 136, Plan 136-01] Domknieto `INVOICE-IDEMP-115`: delegowane faktury Fakturowni uzywaja stabilnego `oid`, lookup-first retry i lokalnego stanu `pending_external`/`failed_retryable`. +- Dodano migracje idempotencji faktur delegowanych, obsluge repozytorium, refaktor `InvoiceService`, `findInvoiceByOid()` w kliencie Fakturowni oraz testy jednostkowe retry/auto-attach. +- Udokumentowano kontrakt `oid`, nowe kolumny `invoices` i luki weryfikacyjne: migracja wymaga dzialajacego MySQL, PHPUnit nie ma w checkoutcie, `sonar-scanner` nie jest w PATH. + +## Zmienione pliki + +- `.paul/PROJECT.md` +- `.paul/ROADMAP.md` +- `.paul/STATE.md` +- `.paul/changelog/2026-05-17.md` +- `.paul/codebase/architecture.md` +- `.paul/codebase/db_schema.md` +- `.paul/codebase/tech_changelog.md` +- `.paul/codebase/todo.md` +- `.paul/phases/136-fakturownia-invoice-idempotency/136-01-PLAN.md` +- `.paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md` +- `DOCS/ARCHITECTURE.md` +- `DOCS/DB_SCHEMA.md` +- `DOCS/TECH_CHANGELOG.md` +- `database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql` +- `src/Modules/Accounting/InvoiceRepository.php` +- `src/Modules/Accounting/InvoiceService.php` +- `src/Modules/Settings/FakturowniaApiClient.php` +- `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index dade3b4..0b0c208 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -233,7 +233,7 @@ tests/ ### InvoiceService (`src/Modules/Accounting/InvoiceService.php`) - `issue(array $params): array` — orchestrator. Walidacja config (active), order details fetch, build snapshots (seller z `company_settings`, buyer merged z payload_json+addresses+manual override, items z VAT-aware netto/brutto split), routing do `issueLocal()` lub `issueDelegated()` zaleznie od `invoice_configs.is_delegated`. - `issueLocal()` — `InvoiceRepository::nextLocalNumber()` (atomowy counter z `invoice_number_counters`) -> `insertLocal()` -> zwraca `{invoice_id, invoice_number, total_gross, mode='local'}`. -- `issueDelegated()` — `FakturowniaApiClient::createInvoice()` PRZED INSERT lokalnym; on success zapis `external_invoice_id`/`external_pdf_url`/`invoice_number` z odpowiedzi API; on failure rzuca `InvoiceIssueException` (zaden wiersz w `invoices`). `invoice_number_counters` NIE jest dotykany dla delegated. +- `issueDelegated()` — Phase 136 retry-safe flow dla Fakturowni. Serwis uzywa stabilnego `oid` z `orders.internal_order_number`, sprawdza `GET /invoices.json?oid=...` przed POST, zapisuje lokalny wiersz `pending_external` przed POST, finalizuje go po sukcesie albo auto-podpina dokument znaleziony po timeoutcie. `invoice_number_counters` NIE jest dotykany dla delegated. - Static `extractBuyerTaxNumber($order, $buyerAddress)` — parsuje NIP z payload_json sciezki: `invoice.address.taxId` (Allegro), `invoice.taxId/nip`, `buyer.tax_number/nip`, `client.nip/tax_number`, top-level `nip/tax_number`. Fallback na `order_addresses.company_tax_number`. ### InvoiceRepository (`src/Modules/Accounting/InvoiceRepository.php`) @@ -244,6 +244,7 @@ tests/ ### FakturowniaApiClient (rozszerzony) - `createInvoice(array $settings, array $invoice)` — POST `https://{prefix}.fakturownia.pl/invoices.json` z body `{api_token, invoice}`. cURL z `SslCertificateResolver`, timeout `$timeoutSeconds`. On 2xx parsuje JSON na `{id, number, view_url, pdf_url, raw}`. On non-2xx rzuca `RuntimeException("HTTP {code}: {error}")`. +- `findInvoiceByOid(array $settings, string $oid)` — GET `https://{prefix}.fakturownia.pl/invoices.json?oid=...&api_token=...`; uzywane do reconciliacji po niepewnym POST i przed retry, bo Fakturownia dokumentuje lookup po `oid`, a nie dokumentuje `Idempotency-Key`. - `buildPdfUrl(prefix, invoiceId, apiToken)` — string-builder dla `https://{prefix}.fakturownia.pl/invoices/{id}.pdf?api_token=...`. Bez fetcha; uzywany w redirect 302. - Dodany `httpPostJson()` (private) odpowiednik istniejacego `httpGet()`. @@ -283,7 +284,7 @@ tests/ - `OrdersController` ctor dostal 2 NEW optional params (default null) — backwards compatible. ### Edge cases / known limits -- INVOICE-IDEMP-115 (`.paul/codebase/todo.md`) — brak idempotencji przy double-POST do Fakturowni gdy odpowiedz nie dotrze; operator musi recznie zweryfikowac w panelu. +- INVOICE-IDEMP-115 (`.paul/codebase/todo.md`) — resolved in Phase 136 przez `external_oid`, `pending_external`, lookup po `oid` i auto-attach po timeoutcie. - Brak `invoice.created` event automatyzacji (per Phase 113 decision). - Brak download+cache PDF z Fakturowni — tylko redirect 302 (kazdy klik na PDF dla delegated faktury fetchuje PDF z Fakturowni). diff --git a/.paul/codebase/db_schema.md b/.paul/codebase/db_schema.md index 56d9284..5e5e0e2 100644 --- a/.paul/codebase/db_schema.md +++ b/.paul/codebase/db_schema.md @@ -775,11 +775,15 @@ UNIQUE: `(config_id, year, month)` | `order_reference_value` | VARCHAR(128) | YES | | | `external_invoice_id` | VARCHAR(128) | YES | Fakturownia invoice id when delegated | | `external_pdf_url` | VARCHAR(500) | YES | URL returned by Fakturownia | +| `external_status` | VARCHAR(32) | YES | Phase 136 delegated state: `pending_external`, `issued`, or `failed_retryable` | +| `external_oid` | VARCHAR(128) | YES | Stable Fakturownia `oid` for idempotency; orderPRO uses `orders.internal_order_number` | +| `external_attempted_at` | DATETIME | YES | Last delegated API attempt or reconciliation timestamp | +| `external_error_message` | VARCHAR(500) | YES | Last retryable delegated API error | | `kind` | VARCHAR(32) | NO | DEFAULT 'vat' | | `created_by` | INT UNSIGNED | YES | | | `created_at` | DATETIME | NO | | -Indexes: `invoices_number_unique`, `invoices_order_idx`, `invoices_config_date_idx (config_id, issue_date)`, `invoices_external_idx` +Indexes: `invoices_number_unique`, `invoices_order_idx`, `invoices_config_date_idx (config_id, issue_date)`, `invoices_external_idx`, `invoices_config_external_oid_unique (config_id, external_oid)` **invoice_number_counters** — Sequential numbering per config/period (mirrors `receipt_number_counters`) | Column | Type | Nullable | Notes | diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md index 703a0f3..69c385e 100644 --- a/.paul/codebase/tech_changelog.md +++ b/.paul/codebase/tech_changelog.md @@ -1,5 +1,23 @@ # Technical Changelog +## 2026-05-17 - Phase 136 Plan 01: Fakturownia Invoice Idempotency + +**Co zrobiono:** +- Dodano migracje `20260517_000118_add_invoice_external_idempotency_state.sql` z polami `invoices.external_status`, `external_oid`, `external_attempted_at`, `external_error_message` oraz unikalnym indeksem `(config_id, external_oid)`. +- `InvoiceService::issueDelegated()` uzywa stabilnego Fakturownia `oid` z `orders.internal_order_number` (fallback `orderpro-{order_id}` tylko gdy brak numeru wewnetrznego). +- Przed zewnetrznym POST system sprawdza Fakturownie przez `GET /invoices.json?oid=...`; znaleziony dokument jest automatycznie podpiety do lokalnego wiersza. +- Delegowane wystawienie tworzy lokalny wiersz `pending_external` przed POST i finalizuje go po odpowiedzi Fakturowni. +- Po timeoutcie lub bledzie polaczenia system ponownie sprawdza `oid`; jesli faktura istnieje, podpina ja i zwraca sukces, a jesli nie istnieje, oznacza wiersz jako `failed_retryable`. +- `FakturowniaApiClient` dostal `findInvoiceByOid()` i wspolna normalizacje odpowiedzi faktury. +- Dodano `FakturowniaInvoiceIdempotencyTest` dla lookup-first retry, sukcesu POST, auto-attach po timeoutcie i retryable failure. + +**Dlaczego:** +- `INVOICE-IDEMP-115` grozil druga faktura w Fakturowni, gdy pierwszy POST utworzyl dokument, ale odpowiedz nie dotarla do orderPRO. Fakturownia dokumentuje lookup po `oid`, a nie dokumentuje `Idempotency-Key`, wiec retry-safe flow opiera sie na stabilnym `oid` i lokalnym stanie. + +**BREAKING / migracja:** +- Wymagana migracja `20260517_000118_add_invoice_external_idempotency_state.sql`. +- Brak zmian breaking dla faktur lokalnych; `invoice_number_counters` pozostaje bez zmian. + ## 2026-05-16 - Phase 135 Plan 01: Accounting Net Correctness **Co zrobiono:** diff --git a/.paul/codebase/todo.md b/.paul/codebase/todo.md index ca58a3c..27cc735 100644 --- a/.paul/codebase/todo.md +++ b/.paul/codebase/todo.md @@ -27,6 +27,9 @@ ### Status audytu Phase 134 (2026-05-16) - **Active / needs operator/API decision** - brak lokalnego `pending_external`, idempotency key i lookupu po referencji przed ponownym POST. W Phase 136 najpierw potwierdzic mozliwosci API Fakturowni. Dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`. +### Status Phase 136 (2026-05-17) +- **Resolved** - delegowane faktury Fakturownia uzywaja stabilnego `oid = orders.internal_order_number`, lokalnego stanu `pending_external`/`failed_retryable`, lookupu `GET /invoices.json?oid=...` przed ponownym POST oraz auto-podpiecia znalezionej faktury po timeoutcie. Fakturownia nie dokumentuje `Idempotency-Key`, wiec deduplikacja opiera sie na `oid`. + ### Kontekst - Phase 115-01 — wystawianie faktury z zamowienia (delegacja Fakturownia). - Flow: `InvoiceService::issueDelegated()` -> POST do Fakturowni -> on success INSERT do `invoices`. @@ -38,7 +41,7 @@ 3. Alternatywa: po bledzie API, przed kolejnym POST, query Fakturowni `GET /invoices.json?q=` zeby sprawdzic czy faktura juz istnieje. ### Status -- Odlozone — operator musi recznie zweryfikowac w panelu Fakturowni przy bledach API. +- Zamkniete w Phase 136 dla nowych delegowanych wystawien. Historyczne duplikaty, jesli istnieja, nie sa backfillowane ani automatycznie usuwane. --- diff --git a/.paul/phases/136-fakturownia-invoice-idempotency/136-01-PLAN.md b/.paul/phases/136-fakturownia-invoice-idempotency/136-01-PLAN.md new file mode 100644 index 0000000..025bab3 --- /dev/null +++ b/.paul/phases/136-fakturownia-invoice-idempotency/136-01-PLAN.md @@ -0,0 +1,239 @@ +--- +phase: 136-fakturownia-invoice-idempotency +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql + - src/Modules/Accounting/InvoiceRepository.php + - src/Modules/Accounting/InvoiceService.php + - src/Modules/Settings/FakturowniaApiClient.php + - tests/Unit/FakturowniaInvoiceIdempotencyTest.php + - DOCS/ARCHITECTURE.md + - DOCS/DB_SCHEMA.md + - DOCS/TECH_CHANGELOG.md + - .paul/codebase/architecture.md + - .paul/codebase/db_schema.md + - .paul/codebase/tech_changelog.md + - .paul/codebase/todo.md +autonomous: true +delegation: auto +--- + + +## Goal +Make delegated Fakturownia invoice issuance retry-safe so an uncertain timeout cannot create a second external invoice for the same order. + +## Purpose +Phase 134 confirmed `INVOICE-IDEMP-115`: `InvoiceService::issueDelegated()` posts to Fakturownia before inserting the local invoice row. If Fakturownia creates the invoice but the HTTP response is lost, an operator retry can currently send a second POST and duplicate the fiscal document. + +## Output +- Delegated invoice creation sends a stable Fakturownia `oid` based on `orders.internal_order_number`. +- Local invoice rows can represent `pending_external` delegated attempts before the external POST finishes. +- Retries first reconcile by `GET /invoices.json?oid=...` and auto-attach the matching Fakturownia invoice when found. +- Focused tests and technical documentation cover timeout/retry behavior. + + + + +- **Strategia** - Czy planowac lokalny stan `pending_external` plus staly `oid` i lookup po `oid` przed ponownym POST? + -> Odpowiedz: tak. +- **OID** - Co ma byc stabilnym identyfikatorem zamowienia w Fakturowni? + -> Odpowiedz: `internal_order_number`. +- **Timeout** - Jezeli POST zwroci blad polaczenia, a lookup po `oid` znajdzie fakture, czy system ma automatycznie podpiac ja lokalnie i pokazac sukces? + -> Odpowiedz: tak, auto-podepnij. + + +## Project Context +@.paul/PROJECT.md +@.paul/ROADMAP.md +@.paul/STATE.md +@.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md +@.paul/codebase/todo.md +@.paul/codebase/architecture.md +@.paul/codebase/db_schema.md +@DOCS/ARCHITECTURE.md +@DOCS/DB_SCHEMA.md + +## Prior Work +@.paul/phases/135-accounting-net-correctness/135-01-SUMMARY.md + +## Source Files +@src/Modules/Accounting/InvoiceService.php +@src/Modules/Accounting/InvoiceRepository.php +@src/Modules/Accounting/InvoiceController.php +@src/Modules/Accounting/InvoiceIssueException.php +@src/Modules/Settings/FakturowniaApiClient.php +@src/Modules/Settings/FakturowniaIntegrationRepository.php +@src/Modules/Settings/InvoiceConfigRepository.php +@routes/web.php + +## External API Context +Official Fakturownia API docs show: +- `GET /invoices.json?oid=nr_zam&api_token=...` for fetching invoices by order id. +- `POST /invoices.json` for creating invoices. +- No documented `Idempotency-Key` support on the invoice create endpoint as of 2026-05-17. + + + +## Required Skills / Tooling + +| Skill / Tool | Priority | When to Invoke | Loaded? | +|--------------|----------|----------------|---------| +| `sonar-scanner` | required | After APPLY, before UNIFY | pending | + +If `sonar-scanner` is still unavailable in PATH, document the gap in SUMMARY/STATE as in prior phases. + + + + +## AC-1: Delegated Invoice Uses Stable OID +```gherkin +Given an order has `internal_order_number` +When a delegated Fakturownia invoice is created +Then the Fakturownia payload contains `oid` equal to that internal order number +And the local invoice row stores the same value in `order_reference_value` +``` + +## AC-2: Retry Does Not Double POST When External Invoice Already Exists +```gherkin +Given a previous delegated invoice attempt for an order is pending or failed after an uncertain response +And Fakturownia returns an invoice for `GET /invoices.json?oid=` +When the operator retries invoice creation +Then orderPRO attaches the existing Fakturownia invoice locally +And no second `POST /invoices.json` is sent +``` + +## AC-3: Successful POST Finalizes Pending Row +```gherkin +Given no existing Fakturownia invoice is found by `oid` +When orderPRO posts a delegated invoice and Fakturownia returns `id` and `number` +Then the pending local row is finalized with `external_invoice_id`, `invoice_number`, `external_pdf_url`, and status `issued` +And the user is redirected to the local invoice preview as before +``` + +## AC-4: Uncertain Failure Remains Recoverable +```gherkin +Given the external POST fails with a connection or timeout error +When orderPRO catches the failure +Then it checks Fakturownia by `oid` before surfacing an error +And if the invoice is found, it auto-attaches and returns success +And if it is not found, the local row remains marked for safe retry rather than allowing blind duplicate creation +``` + +## AC-5: Docs And Tests Describe The Contract +```gherkin +Given the invoice idempotency behavior changes +When APPLY completes +Then unit tests cover lookup-first retry, successful finalize, and auto-attach after uncertain failure +And DOCS plus PAUL codebase notes document the schema and retry contract +``` + + + + + + + Task 1: Add local delegated invoice attempt state + database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql, src/Modules/Accounting/InvoiceRepository.php + + Add minimal schema and repository support for retry-safe delegated invoices. + - Add nullable columns to `invoices`: `external_status`, `external_oid`, `external_attempted_at`, `external_error_message`. + - Add unique index on `(config_id, external_oid)` so one delegated config cannot create multiple local rows for the same Fakturownia `oid`; keep NULL-compatible local invoices unaffected. + - Keep the migration idempotent and DDL-only for no-op branches, following project migration rules. + - Add repository methods for: + - finding an invoice by delegated `config_id` + `external_oid`; + - inserting a delegated pending row before POST; + - finalizing a pending row with external id/number/pdf/status; + - marking a pending row as failed/retryable with a concise error. + - Preserve existing `insertLocal()`, `insertDelegated()`, `findById()`, `findByOrderId()`, and pagination behavior for already issued invoices. + Avoid: schema changes to local numbering counters or any data backfill. + + `php -l src/Modules/Accounting/InvoiceRepository.php` and inspect migration for idempotent DDL/no raw SELECT no-op + AC-2, AC-3, and AC-4 have the local persistence primitives required for safe retry. + + + + Task 2: Implement Fakturownia lookup-first delegated flow + src/Modules/Accounting/InvoiceService.php, src/Modules/Settings/FakturowniaApiClient.php + + Refactor delegated invoice issuance around the stable `oid`. + - Resolve idempotency `oid` from `orders.internal_order_number`; if missing, fall back to a deterministic `orderpro-{order_id}` only to avoid an empty external key. + - Include `oid` in `buildFakturowniaPayload()` and keep `additional_info_desc` for human-readable order context. + - Add `FakturowniaApiClient::findInvoiceByOid(array $settings, string $oid): ?array` using official `GET /invoices.json?oid=...`. + - In `issueDelegated()`: + - check existing local issued invoice for this config/oid first; + - query Fakturownia by `oid` before any POST; + - insert or reuse a local `pending_external` row before POST; + - on POST success, finalize the pending row; + - on connection/timeout/unknown failure, query by `oid` again and auto-finalize if found; + - if still not found, mark the row failed/retryable and throw a clear `InvoiceIssueException` that tells the operator to retry, not to create manually. + - Parse enough fields from Fakturownia lookup/create responses to build `external_invoice_id`, `invoice_number`, `external_pdf_url`, and raw response snapshot if already available locally. + Avoid: deleting remote invoices, changing local invoice issuance, sending seller fields, or reintroducing `department_id` into the payload. + + `php -l src/Modules/Accounting/InvoiceService.php` and `php -l src/Modules/Settings/FakturowniaApiClient.php` + AC-1, AC-2, AC-3, and AC-4 satisfied in runtime flow. + + + + Task 3: Add regression tests and documentation + tests/Unit/FakturowniaInvoiceIdempotencyTest.php, DOCS/ARCHITECTURE.md, DOCS/DB_SCHEMA.md, DOCS/TECH_CHANGELOG.md, .paul/codebase/architecture.md, .paul/codebase/db_schema.md, .paul/codebase/tech_changelog.md, .paul/codebase/todo.md + + Add focused coverage and update technical records. + - Test that a retry with an existing remote invoice found by `oid` finalizes locally without calling `createInvoice()`. + - Test that a successful create finalizes a pending row with issued status and external fields. + - Test that a connection failure followed by successful lookup auto-attaches and returns success. + - Use local stubs or lightweight fake repositories/clients where possible; keep tests focused on service decision flow, not full controller rendering. + - Update DOCS and PAUL codebase notes with new columns, `oid` contract, retry algorithm, and `INVOICE-IDEMP-115` resolution. + - Mention official Fakturownia `GET /invoices.json?oid=...` as the external reconciliation mechanism. + Avoid: broad invoice UI tests or live Fakturownia API calls in unit tests. + + `php -l tests/Unit/FakturowniaInvoiceIdempotencyTest.php`; run targeted PHPUnit when available; `rg -n "INVOICE-IDEMP-115|external_oid|pending_external|Fakturownia.*oid" DOCS .paul/codebase tests src` + AC-5 satisfied and the backlog item is documented as resolved after implementation. + + + + + + +## DO NOT CHANGE +- Local invoice numbering in `invoice_number_counters`. +- Receipt/paragon code and Phase 135 net calculation behavior. +- Fakturownia seller field omission and `department_id` omission decisions from Phase 115. +- Invoice UI layout unless a tiny status display is strictly required by backend data. +- Runtime database configuration; do not wire `DB_HOST_REMOTE` into application runtime. + +## SCOPE LIMITS +- This plan covers delegated Fakturownia invoices only; local invoices remain unchanged. +- No live Fakturownia API call is required during automated tests. +- No historical reconciliation/backfill of existing external duplicates. +- No automation event `invoice.created`; that remains a separate product decision. +- No new dependencies. + + + + +Before declaring plan complete: +- [ ] `php -l src/Modules/Accounting/InvoiceRepository.php` +- [ ] `php -l src/Modules/Accounting/InvoiceService.php` +- [ ] `php -l src/Modules/Settings/FakturowniaApiClient.php` +- [ ] `php -l tests/Unit/FakturowniaInvoiceIdempotencyTest.php` +- [ ] `php bin/migrate.php` if local MySQL/XAMPP is available +- [ ] `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` if PHPUnit exists in checkout +- [ ] `git diff --check` +- [ ] `sonar-scanner` after APPLY, or document unavailable CLI gap +- [ ] All acceptance criteria met + + + +- Delegated Fakturownia invoices use `internal_order_number` as stable `oid`. +- Retrying after uncertain external creation performs lookup-first reconciliation and does not blind POST a duplicate. +- Successful create and remote-found retry both produce a usable local `invoices` row and existing redirect/preview behavior. +- Failed uncertain attempts are stored as retryable local state with a clear error. +- Tests and technical documentation cover the new contract. + + + +After completion, create `.paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md`. + diff --git a/.paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md b/.paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md new file mode 100644 index 0000000..3a3d020 --- /dev/null +++ b/.paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md @@ -0,0 +1,177 @@ +--- +phase: 136-fakturownia-invoice-idempotency +plan: 01 +subsystem: accounting +tags: [fakturownia, invoices, idempotency, oid, retry] + +requires: + - phase: 115 + provides: Delegated Fakturownia invoice issuance flow and local invoice snapshots + - phase: 134 + provides: Confirmed INVOICE-IDEMP-115 backlog item +provides: + - Retry-safe delegated Fakturownia invoice issuance using stable oid + - Local pending/failed external invoice attempt state + - Lookup-first reconciliation before duplicate external POST +affects: [invoices, fakturownia, accounting, retry] + +tech-stack: + added: [] + patterns: [external oid reconciliation, pending external attempt state] + +key-files: + created: + - database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql + - tests/Unit/FakturowniaInvoiceIdempotencyTest.php + modified: + - src/Modules/Accounting/InvoiceRepository.php + - src/Modules/Accounting/InvoiceService.php + - src/Modules/Settings/FakturowniaApiClient.php + - DOCS/ARCHITECTURE.md + - DOCS/DB_SCHEMA.md + - DOCS/TECH_CHANGELOG.md + - .paul/codebase/architecture.md + - .paul/codebase/db_schema.md + - .paul/codebase/tech_changelog.md + - .paul/codebase/todo.md + +key-decisions: + - "Use orders.internal_order_number as Fakturownia oid; fallback to orderpro-{order_id} only if missing." + - "Official GET /invoices.json?oid=... is the reconciliation mechanism because invoice create has no documented Idempotency-Key support." + - "POST failures are retried safely by remote lookup and local failed_retryable state, not by blind duplicate POST." + +patterns-established: + - "Delegated external creates should persist pending local state before the remote POST." + - "Uncertain remote failures should perform lookup-after-failure before surfacing retry instructions." + +duration: ~2h +started: 2026-05-17T15:36:00Z +completed: 2026-05-17T15:36:00Z +--- + +# Phase 136 Plan 01: Fakturownia Invoice Idempotency Summary + +Delegated Fakturownia invoices now use stable `oid` reconciliation and local pending state so retries do not blindly create duplicate external invoices after a timeout. + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~2h | +| Started | 2026-05-17 17:36 Europe/Warsaw | +| Completed | 2026-05-17 17:36 Europe/Warsaw | +| Tasks | 3 completed | +| Files modified | 17, including PAUL transition files | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: Delegated Invoice Uses Stable OID | Pass | `InvoiceService` resolves `oid` from `orders.internal_order_number`, falls back to `orderpro-{order_id}`, sends it in the Fakturownia payload and stores it locally. | +| AC-2: Retry Does Not Double POST When External Invoice Already Exists | Pass | Runtime checks local `(config_id, external_oid)` and `GET /invoices.json?oid=...` before `POST /invoices.json`; unit test covers no second create call. | +| AC-3: Successful POST Finalizes Pending Row | Pass | `InvoiceRepository` creates `pending_external` rows and finalizes them with external id, number, PDF URL and `issued` status. | +| AC-4: Uncertain Failure Remains Recoverable | Pass | On POST exception, the service looks up by `oid`; if found, it auto-attaches, otherwise marks `failed_retryable` and throws retry-safe guidance. | +| AC-5: Docs And Tests Describe The Contract | Pass with env gap | Unit test file and DOCS/PAUL notes were added; PHPUnit execution was blocked because `vendor/bin/phpunit` is missing. | + +## Accomplishments + +- Added invoice idempotency state columns and unique `(config_id, external_oid)` protection for delegated Fakturownia invoices. +- Refactored delegated invoice issuance to lookup local and remote invoices before create, persist pending attempts, finalize success, and auto-attach remote invoices found after uncertain failures. +- Added focused unit coverage for lookup-first retry, pending finalize, auto-attach after connection failure, and retryable failure marking. +- Documented `INVOICE-IDEMP-115` as resolved in DOCS and PAUL codebase notes. + +## Task Commits + +Phase commit: `HEAD feat(136): fakturownia invoice idempotency` + +| Task | Commit | Type | Description | +|------|--------|------|-------------| +| Task 1: Add local delegated invoice attempt state | `HEAD` | feat | Migration and repository support for pending/finalize/failed external invoice attempts. | +| Task 2: Implement Fakturownia lookup-first delegated flow | `HEAD` | feat | Stable `oid`, lookup-first API flow, post-failure reconciliation and auto-attach. | +| Task 3: Add regression tests and documentation | `HEAD` | test/docs | Unit tests plus DOCS/PAUL schema, architecture and changelog updates. | + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql` | Created | Adds delegated external invoice status, oid, attempt timestamp, error message and unique config/oid index. | +| `src/Modules/Accounting/InvoiceRepository.php` | Modified | Adds lookup, pending insert, finalize and failed retryable helpers. | +| `src/Modules/Accounting/InvoiceService.php` | Modified | Implements stable oid, lookup-first create, auto-attach and retry-safe failure handling. | +| `src/Modules/Settings/FakturowniaApiClient.php` | Modified | Adds `findInvoiceByOid()` and shared response normalization. | +| `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` | Created | Covers retry/lookup/finalize/failed idempotency behavior. | +| `DOCS/ARCHITECTURE.md` | Modified | Documents delegated Fakturownia retry algorithm. | +| `DOCS/DB_SCHEMA.md` | Modified | Documents new invoice idempotency columns and unique key. | +| `DOCS/TECH_CHANGELOG.md` | Modified | Records Phase 136 technical change and verification gaps. | +| `.paul/codebase/architecture.md` | Modified | Mirrors architecture contract for PAUL context. | +| `.paul/codebase/db_schema.md` | Modified | Mirrors schema contract for PAUL context. | +| `.paul/codebase/tech_changelog.md` | Modified | Mirrors technical changelog. | +| `.paul/codebase/todo.md` | Modified | Marks `INVOICE-IDEMP-115` as resolved. | +| `.paul/PROJECT.md` | Modified | Marks Phase 136 as shipped and Phase 137 ready. | +| `.paul/ROADMAP.md` | Modified | Closes Phase 136 and advances milestone progress. | +| `.paul/STATE.md` | Modified | Moves loop to complete and routes next action to Phase 137. | +| `.paul/changelog/2026-05-17.md` | Created | Human-readable PAUL daily changelog. | +| `.paul/phases/136-fakturownia-invoice-idempotency/136-01-SUMMARY.md` | Created | This completion summary. | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| Stable `oid` is `orders.internal_order_number`. | Operator selected it and it is already the internal order reference used across the app. | Retries can reconcile the same external invoice deterministically. | +| Use Fakturownia `GET /invoices.json?oid=...` instead of `Idempotency-Key`. | Official docs expose lookup by `oid`; no documented invoice create idempotency header was found. | Idempotency is application-managed and compatible with current API. | +| Keep failed uncertain attempts as `failed_retryable`. | A failure may still have created the remote invoice, so the next attempt must re-check by `oid`. | Operators get a safe retry path without manual duplicate risk. | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Execution mode | 1 | Plan allowed auto delegation, but implementation was done inline because the active developer instruction requires explicit user authorization before spawning agents. No functional impact. | +| Verification gap | 3 | Migration, PHPUnit and Sonar could not run in this environment; gaps are documented below and in STATE. | +| Scope addition | 1 | Added an ad-hoc SQLite repository smoke as a verification fallback while PHPUnit/MySQL were unavailable. | + +### Deferred Items + +- Run `php bin/migrate.php` when local XAMPP MySQL is online. +- Run `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` after project dependencies provide PHPUnit. +- Run `sonar-scanner` when the CLI is available in PATH. + +## Verification Results + +| Command | Result | +|---------|--------| +| `C:\xampp\php\php.exe -l src\Modules\Accounting\InvoiceRepository.php` | Pass | +| `C:\xampp\php\php.exe -l src\Modules\Accounting\InvoiceService.php` | Pass | +| `C:\xampp\php\php.exe -l src\Modules\Settings\FakturowniaApiClient.php` | Pass | +| `C:\xampp\php\php.exe -l tests\Unit\FakturowniaInvoiceIdempotencyTest.php` | Pass | +| `rg -n "INVOICE-IDEMP-115|external_oid|pending_external|Fakturownia.*oid|findInvoiceByOid" DOCS .paul\codebase tests src` | Pass | +| Ad-hoc SQLite repository smoke | Pass: pending/finalize/failed helpers exercised | +| `git diff --check` | Pass, CRLF warnings only | +| `php bin/migrate.php` | Blocked: local MySQL refused connection | +| `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` | Blocked: `vendor/bin/phpunit` missing | +| `sonar-scanner --version` | Blocked: command not found | + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| Local MySQL/XAMPP refused connection during migration run. | Migration left pending and documented as follow-up. | +| `vendor/bin/phpunit` is not present in checkout. | Added tests and verified syntax; PHPUnit execution documented as environment gap. | +| `sonar-scanner` is not available in PATH. | Skill/tooling gap documented in STATE and SUMMARY. | + +## Next Phase Readiness + +**Ready:** +- `INVOICE-IDEMP-115` is implemented and documented. +- Phase 137 can start from a cleaner accounting/Fakturownia backlog state. + +**Concerns:** +- Migration must still be applied on a live database before the new runtime path can persist external attempt state. +- Targeted PHPUnit should be run once project dependencies are available. + +**Blockers:** +- None for planning Phase 137. + +--- +*Phase: 136-fakturownia-invoice-idempotency, Plan: 01* +*Completed: 2026-05-17* diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md index 26a1acf..669d722 100644 --- a/DOCS/ARCHITECTURE.md +++ b/DOCS/ARCHITECTURE.md @@ -314,6 +314,17 @@ tests/ ### Migration 20260512_000109 - Wybiera aktywna instancje Fakturowni jako zachowana; fallback: najczesciej uzywana w `invoice_configs`, potem najnizsze id. - Przepina delegowane `invoice_configs.integration_id` na zachowana instancje i zeruje `integration_id` dla lokalnych konfiguracji. + +## Phase 136 - Fakturownia Invoice Idempotency + +### Delegated invoice retry contract +- `InvoiceService::issueDelegated()` uses stable Fakturownia `oid` from `orders.internal_order_number`; if the internal number is unexpectedly empty, it falls back to `orderpro-{order_id}`. +- `buildFakturowniaPayload()` sends `oid` and keeps `additional_info_desc = "Zamowienie: ..."` only as a human-readable note. +- Before any external POST, the service calls `FakturowniaApiClient::findInvoiceByOid()` (`GET /invoices.json?oid=...`). If a remote invoice already exists, orderPRO creates/reuses the local row and finalizes it without another POST. +- Delegated invoices are inserted locally as `external_status='pending_external'` before `POST /invoices.json`. On success the row becomes `external_status='issued'` and stores `external_invoice_id`, `invoice_number`, `external_pdf_url`. +- If POST fails with a timeout/connection/unknown error, orderPRO checks Fakturownia by `oid` again. A found invoice is auto-attached; otherwise the row is marked `failed_retryable` with `external_error_message`, and the operator can retry safely. +- `InvoiceRepository::findByConfigAndExternalOid()`, `insertDelegatedPending()`, `finalizeDelegatedExternal()`, and `markDelegatedExternalFailed()` are the persistence API for this flow. +- The unique index `(config_id, external_oid)` prevents duplicate local delegated attempts for one stable external order id while nullable `external_oid` leaves local invoices unchanged. - Usuwa nadmiarowe rekordy `fakturownia_integration_settings` i `integrations.type='fakturownia'` po przepieciu zaleznosci. ## Phase 108 — Delivery Status Management diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md index 84d1648..e12fedb 100644 --- a/DOCS/DB_SCHEMA.md +++ b/DOCS/DB_SCHEMA.md @@ -803,10 +803,16 @@ UNIQUE: `(config_id, year, month)` | `order_reference_value` | VARCHAR(128) | YES | | | `external_invoice_id` | VARCHAR(128) | YES | Fakturownia invoice id for delegated invoices | | `external_pdf_url` | VARCHAR(500) | YES | Fakturownia PDF URL for delegated invoices | +| `external_status` | VARCHAR(32) | YES | Phase 136 delegated state: `pending_external`, `issued`, or `failed_retryable` | +| `external_oid` | VARCHAR(128) | YES | Stable Fakturownia `oid` for idempotency; orderPRO uses `orders.internal_order_number` | +| `external_attempted_at` | DATETIME | YES | Last delegated API attempt or reconciliation timestamp | +| `external_error_message` | VARCHAR(500) | YES | Last retryable delegated API error | | `kind` | VARCHAR(32) | NO | DEFAULT `vat` | | `created_by` | INT UNSIGNED | YES | | | `created_at` | DATETIME | NO | | +Indexes: `invoices_config_external_oid_unique (config_id, external_oid)` prevents duplicate local delegated attempts for one Fakturownia `oid`; nullable `external_oid` keeps local invoices unaffected. + **invoice_number_counters** - Sequential numbering per config/period | Column | Type | Nullable | Notes | |--------|------|----------|-------| diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md index 1a87275..90e5f11 100644 --- a/DOCS/TECH_CHANGELOG.md +++ b/DOCS/TECH_CHANGELOG.md @@ -1,5 +1,23 @@ # Technical Changelog +## 2026-05-17 - Phase 136 Plan 01: Fakturownia Invoice Idempotency + +**Co zrobiono:** +- Dodano migracje `20260517_000118_add_invoice_external_idempotency_state.sql` z polami `invoices.external_status`, `external_oid`, `external_attempted_at`, `external_error_message` oraz unikalnym indeksem `(config_id, external_oid)`. +- `InvoiceService::issueDelegated()` uzywa stabilnego Fakturownia `oid` z `orders.internal_order_number` (fallback `orderpro-{order_id}` tylko gdy brak numeru wewnetrznego). +- Przed zewnetrznym POST system sprawdza Fakturownie przez `GET /invoices.json?oid=...`; znaleziony dokument jest automatycznie podpiety do lokalnego wiersza. +- Delegowane wystawienie tworzy lokalny wiersz `pending_external` przed POST i finalizuje go po odpowiedzi Fakturowni. +- Po timeoutcie lub bledzie polaczenia system ponownie sprawdza `oid`; jesli faktura istnieje, podpina ja i zwraca sukces, a jesli nie istnieje, oznacza wiersz jako `failed_retryable`. +- `FakturowniaApiClient` dostal `findInvoiceByOid()` i wspolna normalizacje odpowiedzi faktury. +- Dodano `FakturowniaInvoiceIdempotencyTest` dla lookup-first retry, sukcesu POST, auto-attach po timeoutcie i retryable failure. + +**Dlaczego:** +- `INVOICE-IDEMP-115` grozil druga faktura w Fakturowni, gdy pierwszy POST utworzyl dokument, ale odpowiedz nie dotarla do orderPRO. Fakturownia dokumentuje lookup po `oid`, a nie dokumentuje `Idempotency-Key`, wiec retry-safe flow opiera sie na stabilnym `oid` i lokalnym stanie. + +**BREAKING / migracja:** +- Wymagana migracja `20260517_000118_add_invoice_external_idempotency_state.sql`. +- Brak zmian breaking dla faktur lokalnych; `invoice_number_counters` pozostaje bez zmian. + ## 2026-05-16 - Phase 135 Plan 01: Accounting Net Correctness **Co zrobiono:** diff --git a/database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql b/database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql new file mode 100644 index 0000000..825ace8 --- /dev/null +++ b/database/migrations/20260517_000118_add_invoice_external_idempotency_state.sql @@ -0,0 +1,63 @@ +-- Phase 136-01: delegated Fakturownia invoice idempotency state. +-- Adds retry-safe local state for external invoice creation attempts. +-- Idempotent guards use information_schema and DDL no-op comments, not SELECT no-op result sets. + +SET @col_external_status := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + AND COLUMN_NAME = 'external_status' +); +SET @sql_external_status := IF(@col_external_status = 0, + 'ALTER TABLE invoices ADD COLUMN external_status VARCHAR(32) NULL AFTER external_pdf_url', + 'ALTER TABLE invoices COMMENT = ''phase-136 external_status no-op''' +); +PREPARE s1 FROM @sql_external_status; EXECUTE s1; DEALLOCATE PREPARE s1; + +SET @col_external_oid := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + AND COLUMN_NAME = 'external_oid' +); +SET @sql_external_oid := IF(@col_external_oid = 0, + 'ALTER TABLE invoices ADD COLUMN external_oid VARCHAR(128) NULL AFTER external_status', + 'ALTER TABLE invoices COMMENT = ''phase-136 external_oid no-op''' +); +PREPARE s2 FROM @sql_external_oid; EXECUTE s2; DEALLOCATE PREPARE s2; + +SET @col_external_attempted_at := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + AND COLUMN_NAME = 'external_attempted_at' +); +SET @sql_external_attempted_at := IF(@col_external_attempted_at = 0, + 'ALTER TABLE invoices ADD COLUMN external_attempted_at DATETIME NULL AFTER external_oid', + 'ALTER TABLE invoices COMMENT = ''phase-136 external_attempted_at no-op''' +); +PREPARE s3 FROM @sql_external_attempted_at; EXECUTE s3; DEALLOCATE PREPARE s3; + +SET @col_external_error_message := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + AND COLUMN_NAME = 'external_error_message' +); +SET @sql_external_error_message := IF(@col_external_error_message = 0, + 'ALTER TABLE invoices ADD COLUMN external_error_message VARCHAR(500) NULL AFTER external_attempted_at', + 'ALTER TABLE invoices COMMENT = ''phase-136 external_error_message no-op''' +); +PREPARE s4 FROM @sql_external_error_message; EXECUTE s4; DEALLOCATE PREPARE s4; + +SET @idx_external_oid := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + AND INDEX_NAME = 'invoices_config_external_oid_unique' +); +SET @sql_external_oid_idx := IF(@idx_external_oid = 0, + 'ALTER TABLE invoices ADD UNIQUE KEY invoices_config_external_oid_unique (config_id, external_oid)', + 'ALTER TABLE invoices COMMENT = ''phase-136 external_oid idx no-op''' +); +PREPARE s5 FROM @sql_external_oid_idx; EXECUTE s5; DEALLOCATE PREPARE s5; diff --git a/src/Modules/Accounting/InvoiceRepository.php b/src/Modules/Accounting/InvoiceRepository.php index 15f159e..d555c12 100644 --- a/src/Modules/Accounting/InvoiceRepository.php +++ b/src/Modules/Accounting/InvoiceRepository.php @@ -7,6 +7,10 @@ use PDO; final class InvoiceRepository { + private const EXTERNAL_STATUS_PENDING = 'pending_external'; + private const EXTERNAL_STATUS_ISSUED = 'issued'; + private const EXTERNAL_STATUS_FAILED = 'failed_retryable'; + public function __construct( private readonly PDO $pdo ) { @@ -69,6 +73,109 @@ final class InvoiceRepository return $this->insert($data, isDelegated: true); } + /** + * @return array|null + */ + public function findByConfigAndExternalOid(int $configId, string $externalOid): ?array + { + $statement = $this->pdo->prepare( + 'SELECT * FROM invoices + WHERE config_id = :config_id AND external_oid = :external_oid + LIMIT 1' + ); + $statement->execute([ + 'config_id' => $configId, + 'external_oid' => $externalOid, + ]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + + return is_array($row) ? $row : null; + } + + /** + * @param array $data + */ + public function insertDelegatedPending(array $data): int + { + $statement = $this->pdo->prepare( + 'INSERT INTO invoices ( + order_id, config_id, invoice_number, issue_date, sale_date, payment_due_date, + seller_data_json, buyer_data_json, items_json, + total_net, total_gross, order_reference_value, + external_invoice_id, external_pdf_url, external_status, external_oid, + external_attempted_at, external_error_message, kind, created_by + ) VALUES ( + :order_id, :config_id, :invoice_number, :issue_date, :sale_date, :payment_due_date, + :seller_data_json, :buyer_data_json, :items_json, + :total_net, :total_gross, :order_reference_value, + NULL, NULL, :external_status, :external_oid, + NOW(), NULL, :kind, :created_by + )' + ); + + $externalOid = (string) $data['external_oid']; + $statement->execute([ + 'order_id' => (int) $data['order_id'], + 'config_id' => (int) $data['config_id'], + 'invoice_number' => $this->buildPendingInvoiceNumber((int) $data['config_id'], $externalOid), + 'issue_date' => (string) $data['issue_date'], + 'sale_date' => (string) $data['sale_date'], + 'payment_due_date' => $data['payment_due_date'] ?? null, + 'seller_data_json' => (string) $data['seller_data_json'], + 'buyer_data_json' => $data['buyer_data_json'], + 'items_json' => (string) $data['items_json'], + 'total_net' => (string) $data['total_net'], + 'total_gross' => (string) $data['total_gross'], + 'order_reference_value' => $externalOid, + 'external_status' => self::EXTERNAL_STATUS_PENDING, + 'external_oid' => $externalOid, + 'kind' => (string) ($data['kind'] ?? 'vat'), + 'created_by' => $data['created_by'] ?? null, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + /** + * @param array $data + */ + public function finalizeDelegatedExternal(int $invoiceId, array $data): void + { + $statement = $this->pdo->prepare( + 'UPDATE invoices + SET invoice_number = :invoice_number, + external_invoice_id = :external_invoice_id, + external_pdf_url = :external_pdf_url, + external_status = :external_status, + external_attempted_at = NOW(), + external_error_message = NULL + WHERE id = :id' + ); + $statement->execute([ + 'id' => $invoiceId, + 'invoice_number' => (string) $data['invoice_number'], + 'external_invoice_id' => (string) $data['external_invoice_id'], + 'external_pdf_url' => $data['external_pdf_url'] ?? null, + 'external_status' => self::EXTERNAL_STATUS_ISSUED, + ]); + } + + public function markDelegatedExternalFailed(int $invoiceId, string $message): void + { + $statement = $this->pdo->prepare( + 'UPDATE invoices + SET external_status = :external_status, + external_attempted_at = NOW(), + external_error_message = :external_error_message + WHERE id = :id' + ); + $statement->execute([ + 'id' => $invoiceId, + 'external_status' => self::EXTERNAL_STATUS_FAILED, + 'external_error_message' => mb_substr($message, 0, 500), + ]); + } + /** * @param array $data */ @@ -79,12 +186,14 @@ final class InvoiceRepository order_id, config_id, invoice_number, issue_date, sale_date, payment_due_date, seller_data_json, buyer_data_json, items_json, total_net, total_gross, order_reference_value, - external_invoice_id, external_pdf_url, kind, created_by + external_invoice_id, external_pdf_url, external_status, external_oid, + external_attempted_at, external_error_message, kind, created_by ) VALUES ( :order_id, :config_id, :invoice_number, :issue_date, :sale_date, :payment_due_date, :seller_data_json, :buyer_data_json, :items_json, :total_net, :total_gross, :order_reference_value, - :external_invoice_id, :external_pdf_url, :kind, :created_by + :external_invoice_id, :external_pdf_url, :external_status, :external_oid, + :external_attempted_at, :external_error_message, :kind, :created_by )' ); @@ -103,6 +212,10 @@ final class InvoiceRepository 'order_reference_value' => $data['order_reference_value'] ?? null, 'external_invoice_id' => $isDelegated ? ($data['external_invoice_id'] ?? null) : null, 'external_pdf_url' => $isDelegated ? ($data['external_pdf_url'] ?? null) : null, + 'external_status' => $isDelegated ? self::EXTERNAL_STATUS_ISSUED : null, + 'external_oid' => $isDelegated ? ($data['external_oid'] ?? null) : null, + 'external_attempted_at' => $isDelegated ? date('Y-m-d H:i:s') : null, + 'external_error_message' => null, 'kind' => (string) ($data['kind'] ?? 'vat'), 'created_by' => $data['created_by'] ?? null, ]); @@ -233,4 +346,16 @@ final class InvoiceRepository 'per_page' => $perPage, ]; } + + private function buildPendingInvoiceNumber(int $configId, string $externalOid): string + { + $base = preg_replace('/[^A-Za-z0-9_-]+/', '-', $externalOid) ?: (string) $externalOid; + $hash = substr(sha1($externalOid), 0, 8); + $prefix = 'PENDING-' . $configId . '-'; + $suffix = '-' . $hash; + $maxBaseLength = max(1, 64 - strlen($prefix) - strlen($suffix)); + $candidate = $prefix . substr(trim($base, '-'), 0, $maxBaseLength) . $suffix; + + return substr($candidate, 0, 64); + } } diff --git a/src/Modules/Accounting/InvoiceService.php b/src/Modules/Accounting/InvoiceService.php index 6a35216..af2df21 100644 --- a/src/Modules/Accounting/InvoiceService.php +++ b/src/Modules/Accounting/InvoiceService.php @@ -63,6 +63,7 @@ final class InvoiceService $saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate); $paymentDueDate = $this->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7)); $orderReference = $this->resolveOrderReference($config, $order); + $externalOid = $this->resolveFakturowniaOid($orderId, $order); $sellerSnapshot = $this->buildSellerSnapshot(); $buyerSnapshot = $this->buildBuyerSnapshot($order, $addresses, $params); @@ -84,6 +85,7 @@ final class InvoiceService $itemsSnapshot, $totalNet, $totalGross, + $externalOid, $orderReference, $kind, $params['created_by'] ?? null @@ -127,6 +129,7 @@ final class InvoiceService array $itemsSnapshot, float $totalNet, float $totalGross, + string $externalOid, string $orderReference, string $kind, ?int $createdBy @@ -213,53 +216,60 @@ final class InvoiceService $sellerSnapshot, $buyerSnapshot, $itemsSnapshot, + $externalOid, $orderReference, (int) ($config['payment_to_days'] ?? 7), (string) ($account['department_id'] ?? '') ); - try { - $response = $this->fakturowniaApi->createInvoice([ - 'account_prefix' => $prefix, - 'api_token' => $apiToken, - ], $payload); - } catch (Throwable $e) { - throw new InvoiceIssueException('Fakturownia: ' . $e->getMessage()); - } - - $externalId = trim((string) ($response['id'] ?? '')); - $externalNumber = trim((string) ($response['number'] ?? '')); - $externalPdfUrl = trim((string) ($response['pdf_url'] ?? $response['view_url'] ?? '')); - - if ($externalId === '' || $externalNumber === '') { - throw new InvoiceIssueException('Fakturownia zwrocila niekompletna odpowiedz (brak id/number).'); - } - - $invoiceId = $this->invoices->insertDelegated([ - 'order_id' => $orderId, - 'config_id' => $configId, - 'invoice_number' => $externalNumber, - 'issue_date' => $issueDate, - 'sale_date' => $saleDate, - 'payment_due_date' => $paymentDueDate, - 'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE), - 'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null, - 'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE), - 'total_net' => number_format($totalNet, 2, '.', ''), - 'total_gross' => number_format($totalGross, 2, '.', ''), - 'order_reference_value' => $orderReference !== '' ? $orderReference : null, - 'external_invoice_id' => $externalId, - 'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null, - 'kind' => $kind, - 'created_by' => $createdBy, - ]); - - return [ - 'invoice_id' => $invoiceId, - 'invoice_number' => $externalNumber, - 'total_gross' => number_format($totalGross, 2, '.', ''), - 'mode' => 'delegated', + $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 . ' Sprobuj ponownie - orderPRO uzyje tego samego oid i najpierw sprawdzi Fakturownie.'); + } + + return $this->finalizeDelegatedInvoice($invoiceId, $response); } private function resolveIssueDate(string $override): string @@ -326,6 +336,19 @@ final class InvoiceService return ''; } + /** + * @param array $order + */ + private function resolveFakturowniaOid(int $orderId, array $order): string + { + $internalNumber = trim((string) ($order['internal_order_number'] ?? '')); + if ($internalNumber !== '') { + return $internalNumber; + } + + return 'orderpro-' . $orderId; + } + /** * @return array */ @@ -551,6 +574,7 @@ final class InvoiceService array $sellerSnapshot, ?array $buyerSnapshot, array $itemsSnapshot, + string $externalOid, string $orderReference, int $paymentToDays, string $departmentId @@ -569,6 +593,7 @@ final class InvoiceService // 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, @@ -596,8 +621,9 @@ final class InvoiceService $invoice['buyer_email'] = (string) ($buyerSnapshot['email'] ?? ''); } - if ($orderReference !== '') { - $invoice['additional_info_desc'] = 'Zamowienie: ' . $orderReference; + $descriptionReference = $orderReference !== '' ? $orderReference : $externalOid; + if ($descriptionReference !== '') { + $invoice['additional_info_desc'] = 'Zamowienie: ' . $descriptionReference; } // department_id celowo pominiete — konta Fakturowni z podwyzszonym @@ -608,4 +634,129 @@ final class InvoiceService return $invoice; } + + /** + * @param array $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $itemsSnapshot + * @return array + */ + private function buildDelegatedInvoiceData( + int $orderId, + int $configId, + string $issueDate, + string $saleDate, + string $paymentDueDate, + array $sellerSnapshot, + ?array $buyerSnapshot, + array $itemsSnapshot, + float $totalNet, + float $totalGross, + string $externalOid, + string $kind, + ?int $createdBy + ): array { + return [ + 'order_id' => $orderId, + 'config_id' => $configId, + 'issue_date' => $issueDate, + 'sale_date' => $saleDate, + 'payment_due_date' => $paymentDueDate, + 'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE), + 'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null, + 'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE), + 'total_net' => number_format($totalNet, 2, '.', ''), + 'total_gross' => number_format($totalGross, 2, '.', ''), + 'external_oid' => $externalOid, + 'kind' => $kind, + 'created_by' => $createdBy, + ]; + } + + /** + * @param array $settings + * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array}|null + */ + private function findRemoteInvoiceByOid(array $settings, string $externalOid): ?array + { + try { + return $this->fakturowniaApi->findInvoiceByOid($settings, $externalOid); + } catch (Throwable) { + return null; + } + } + + /** + * @param array|null $local + * @param array $pendingData + * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + */ + private function attachRemoteInvoice(?array $local, array $pendingData, array $remote): array + { + $invoiceId = $local !== null + ? (int) ($local['id'] ?? 0) + : $this->invoices->insertDelegatedPending($pendingData); + + return $this->finalizeDelegatedInvoice($invoiceId, $remote); + } + + /** + * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + */ + private function finalizeDelegatedInvoice(int $invoiceId, array $remote): array + { + $externalId = trim((string) ($remote['id'] ?? '')); + $externalNumber = trim((string) ($remote['number'] ?? '')); + $externalPdfUrl = trim((string) ($remote['pdf_url'] ?? $remote['view_url'] ?? '')); + + if ($externalId === '' || $externalNumber === '') { + throw new InvoiceIssueException('Fakturownia zwrocila niekompletna odpowiedz (brak id/number).'); + } + + $this->invoices->finalizeDelegatedExternal($invoiceId, [ + 'invoice_number' => $externalNumber, + 'external_invoice_id' => $externalId, + 'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null, + ]); + + $invoice = $this->invoices->findById($invoiceId); + $totalGross = is_array($invoice) ? (float) ($invoice['total_gross'] ?? 0) : 0.0; + + return [ + 'invoice_id' => $invoiceId, + 'invoice_number' => $externalNumber, + 'total_gross' => number_format($totalGross, 2, '.', ''), + 'mode' => 'delegated', + ]; + } + + /** + * @param array|null $invoice + */ + private function isIssuedDelegatedInvoice(?array $invoice): bool + { + if ($invoice === null) { + return false; + } + + return trim((string) ($invoice['external_invoice_id'] ?? '')) !== '' + && trim((string) ($invoice['invoice_number'] ?? '')) !== '' + && (string) ($invoice['external_status'] ?? 'issued') !== 'pending_external'; + } + + /** + * @param array $invoice + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + */ + private function resultFromLocalDelegatedInvoice(array $invoice): array + { + return [ + 'invoice_id' => (int) ($invoice['id'] ?? 0), + 'invoice_number' => (string) ($invoice['invoice_number'] ?? ''), + 'total_gross' => number_format((float) ($invoice['total_gross'] ?? 0), 2, '.', ''), + 'mode' => 'delegated', + ]; + } } diff --git a/src/Modules/Settings/FakturowniaApiClient.php b/src/Modules/Settings/FakturowniaApiClient.php index 7521747..b590651 100644 --- a/src/Modules/Settings/FakturowniaApiClient.php +++ b/src/Modules/Settings/FakturowniaApiClient.php @@ -100,28 +100,56 @@ final class FakturowniaApiClient throw new RuntimeException('HTTP ' . $httpCode . ': ' . $msg); } + return $this->normalizeInvoiceResponse($rawBody, $prefix, $apiToken); + } + + /** + * GET /invoices.json?oid=... - reconciles invoices created by an earlier uncertain POST. + * + * @param array{account_prefix: string, api_token: string} $settings + * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array}|null + */ + public function findInvoiceByOid(array $settings, string $oid): ?array + { + $prefix = strtolower(trim((string) ($settings['account_prefix'] ?? ''))); + $apiToken = trim((string) ($settings['api_token'] ?? '')); + $oid = trim($oid); + + if ($prefix === '' || $apiToken === '') { + throw new RuntimeException('Brak prefiksu konta lub tokenu API.'); + } + if ($oid === '') { + return null; + } + + $url = $this->buildUrl($prefix, '/invoices.json') + . '?oid=' . rawurlencode($oid) + . '&api_token=' . rawurlencode($apiToken); + [$rawBody, $httpCode, $curlError] = $this->httpGet($url); + + if ($curlError !== null) { + throw new RuntimeException('Blad polaczenia: ' . $curlError); + } + + if ($httpCode < 200 || $httpCode >= 300) { + $msg = $this->resolveErrorMessage($rawBody); + if ($msg === '') { + $msg = 'HTTP ' . $httpCode; + } + throw new RuntimeException('HTTP ' . $httpCode . ': ' . $msg); + } + $decoded = json_decode($rawBody, true); if (!is_array($decoded)) { throw new RuntimeException('Niepoprawna odpowiedz JSON od Fakturowni.'); } - $id = $decoded['id'] ?? null; - $number = $decoded['number'] ?? $decoded['full_number'] ?? null; - - if ($id === null || $number === null) { - throw new RuntimeException('Odpowiedz Fakturowni nie zawiera id/number.'); + $invoice = $this->firstInvoiceFromListResponse($decoded); + if ($invoice === null) { + return null; } - $viewUrl = (string) ($decoded['view_url'] ?? ''); - $pdfUrl = $this->buildPdfUrl($prefix, (string) $id, $apiToken); - - return [ - 'id' => (string) $id, - 'number' => (string) $number, - 'view_url' => $viewUrl, - 'pdf_url' => $pdfUrl, - 'raw' => $decoded, - ]; + return $this->normalizeInvoiceArray($invoice, $prefix, $apiToken); } /** @@ -139,6 +167,63 @@ final class FakturowniaApiClient return 'https://' . $prefix . '.fakturownia.pl' . $path; } + /** + * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array} + */ + private function normalizeInvoiceResponse(string $rawBody, string $prefix, string $apiToken): array + { + $decoded = json_decode($rawBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Niepoprawna odpowiedz JSON od Fakturowni.'); + } + + return $this->normalizeInvoiceArray($decoded, $prefix, $apiToken); + } + + /** + * @param array $invoice + * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array} + */ + private function normalizeInvoiceArray(array $invoice, string $prefix, string $apiToken): array + { + $id = $invoice['id'] ?? null; + $number = $invoice['number'] ?? $invoice['full_number'] ?? null; + + if ($id === null || $number === null) { + throw new RuntimeException('Odpowiedz Fakturowni nie zawiera id/number.'); + } + + $viewUrl = (string) ($invoice['view_url'] ?? ''); + $pdfUrl = $this->buildPdfUrl($prefix, (string) $id, $apiToken); + + return [ + 'id' => (string) $id, + 'number' => (string) $number, + 'view_url' => $viewUrl, + 'pdf_url' => $pdfUrl, + 'raw' => $invoice, + ]; + } + + /** + * @param array $decoded + * @return array|null + */ + private function firstInvoiceFromListResponse(array $decoded): ?array + { + $list = isset($decoded['invoices']) && is_array($decoded['invoices']) + ? $decoded['invoices'] + : $decoded; + + foreach ($list as $row) { + if (is_array($row)) { + return $row; + } + } + + return null; + } + /** * @return array{0: string, 1: int, 2: ?string} */ diff --git a/tests/Unit/FakturowniaInvoiceIdempotencyTest.php b/tests/Unit/FakturowniaInvoiceIdempotencyTest.php new file mode 100644 index 0000000..9527cc4 --- /dev/null +++ b/tests/Unit/FakturowniaInvoiceIdempotencyTest.php @@ -0,0 +1,292 @@ +invoices = $this->createMock(InvoiceRepository::class); + $this->invoiceConfigs = $this->createMock(InvoiceConfigRepository::class); + $this->companySettings = $this->createMock(CompanySettingsRepository::class); + $this->orders = $this->createMock(OrdersRepository::class); + $this->fakturownia = $this->createMock(FakturowniaIntegrationRepository::class); + $this->api = $this->createMock(FakturowniaApiClient::class); + + $this->seedCommonDependencies(); + } + + public function testRetryAttachesExistingRemoteInvoiceWithoutSecondPost(): void + { + $this->invoices + ->method('findByConfigAndExternalOid') + ->with(5, 'OP/2026/001') + ->willReturn(null); + $this->api + ->expects($this->once()) + ->method('findInvoiceByOid') + ->with($this->apiSettings(), 'OP/2026/001') + ->willReturn($this->remoteInvoice('9001', 'FV/12/05/2026')); + $this->api + ->expects($this->never()) + ->method('createInvoice'); + $this->invoices + ->expects($this->once()) + ->method('insertDelegatedPending') + ->with($this->callback(static fn(array $data): bool => ($data['external_oid'] ?? '') === 'OP/2026/001')) + ->willReturn(77); + $this->invoices + ->expects($this->once()) + ->method('finalizeDelegatedExternal') + ->with(77, $this->callback($this->externalFinalizeData('9001', 'FV/12/05/2026'))); + $this->invoices + ->method('findById') + ->with(77) + ->willReturn(['id' => 77, 'total_gross' => '123.00']); + + $result = $this->createService()->issue($this->issueParams()); + + self::assertSame(77, $result['invoice_id']); + self::assertSame('FV/12/05/2026', $result['invoice_number']); + self::assertSame('delegated', $result['mode']); + } + + public function testSuccessfulPostFinalizesPendingInvoiceWithStableOid(): void + { + $this->invoices + ->method('findByConfigAndExternalOid') + ->willReturn(null); + $this->api + ->expects($this->once()) + ->method('findInvoiceByOid') + ->willReturn(null); + $this->invoices + ->expects($this->once()) + ->method('insertDelegatedPending') + ->with($this->callback(static fn(array $data): bool => ($data['external_oid'] ?? '') === 'OP/2026/001')) + ->willReturn(78); + $this->api + ->expects($this->once()) + ->method('createInvoice') + ->with( + $this->apiSettings(), + $this->callback(static fn(array $payload): bool => ($payload['oid'] ?? '') === 'OP/2026/001') + ) + ->willReturn($this->remoteInvoice('9002', 'FV/13/05/2026')); + $this->invoices + ->expects($this->once()) + ->method('finalizeDelegatedExternal') + ->with(78, $this->callback($this->externalFinalizeData('9002', 'FV/13/05/2026'))); + $this->invoices + ->method('findById') + ->with(78) + ->willReturn(['id' => 78, 'total_gross' => '123.00']); + + $result = $this->createService()->issue($this->issueParams()); + + self::assertSame(78, $result['invoice_id']); + self::assertSame('FV/13/05/2026', $result['invoice_number']); + } + + public function testConnectionFailureThenLookupAutoAttachesInvoice(): void + { + $this->invoices + ->method('findByConfigAndExternalOid') + ->willReturn(null); + $this->api + ->expects($this->exactly(2)) + ->method('findInvoiceByOid') + ->willReturnOnConsecutiveCalls(null, $this->remoteInvoice('9003', 'FV/14/05/2026')); + $this->invoices + ->expects($this->once()) + ->method('insertDelegatedPending') + ->willReturn(79); + $this->api + ->expects($this->once()) + ->method('createInvoice') + ->willThrowException(new RuntimeException('Blad polaczenia: timeout')); + $this->invoices + ->expects($this->once()) + ->method('finalizeDelegatedExternal') + ->with(79, $this->callback($this->externalFinalizeData('9003', 'FV/14/05/2026'))); + $this->invoices + ->expects($this->never()) + ->method('markDelegatedExternalFailed'); + $this->invoices + ->method('findById') + ->with(79) + ->willReturn(['id' => 79, 'total_gross' => '123.00']); + + $result = $this->createService()->issue($this->issueParams()); + + self::assertSame(79, $result['invoice_id']); + self::assertSame('FV/14/05/2026', $result['invoice_number']); + } + + public function testConnectionFailureWithoutRemoteMatchMarksRetryableFailure(): void + { + $this->invoices + ->method('findByConfigAndExternalOid') + ->willReturn(null); + $this->api + ->method('findInvoiceByOid') + ->willReturn(null); + $this->invoices + ->expects($this->once()) + ->method('insertDelegatedPending') + ->willReturn(80); + $this->api + ->expects($this->once()) + ->method('createInvoice') + ->willThrowException(new RuntimeException('Blad polaczenia: timeout')); + $this->invoices + ->expects($this->once()) + ->method('markDelegatedExternalFailed') + ->with(80, $this->stringContains('timeout')); + + $this->expectException(InvoiceIssueException::class); + $this->expectExceptionMessage('Sprobuj ponownie'); + + $this->createService()->issue($this->issueParams()); + } + + private function seedCommonDependencies(): void + { + $this->invoiceConfigs + ->method('findById') + ->with(5) + ->willReturn([ + 'id' => 5, + 'is_active' => 1, + 'is_delegated' => 1, + 'integration_id' => 12, + 'payment_to_days' => 7, + 'default_kind' => 'vat', + 'sale_date_source' => 'issue_date', + 'order_reference' => 'integration', + ]); + $this->orders + ->method('findDetails') + ->with(44) + ->willReturn([ + 'order' => [ + 'id' => 44, + 'internal_order_number' => 'OP/2026/001', + 'external_order_number' => 'EXT-44', + 'delivery_price' => 0, + 'buyer_email' => 'buyer@example.test', + ], + 'items' => [[ + 'name' => 'Produkt', + 'quantity' => 1, + 'original_price_with_tax' => 123.0, + 'vat' => 23.0, + ]], + 'addresses' => [[ + 'address_type' => 'invoice', + 'name' => 'Jan Kowalski', + 'company_name' => 'Firma Test', + 'company_tax_number' => '1234567890', + 'street_name' => 'Testowa', + 'street_number' => '1', + 'city' => 'Rzeszow', + 'zip_code' => '35-001', + 'email' => 'buyer@example.test', + ]], + 'payments' => [], + ]); + $this->companySettings + ->method('getSettings') + ->willReturn([ + 'company_name' => 'Sprzedawca', + 'tax_number' => '1112223334', + 'street' => 'Firmowa 1', + 'city' => 'Rzeszow', + 'postal_code' => '35-001', + ]); + $this->fakturownia + ->method('findByIntegrationId') + ->with(12) + ->willReturn(['integration_id' => 12, 'account_prefix' => 'demo']); + $this->fakturownia + ->method('getDecryptedToken') + ->with(12) + ->willReturn('secret-token'); + } + + /** + * @return array + */ + private function issueParams(): array + { + return [ + 'order_id' => 44, + 'config_id' => 5, + 'issue_date_override' => '2026-05-17 12:00:00', + 'created_by' => 7, + ]; + } + + /** + * @return array{account_prefix: string, api_token: string} + */ + private function apiSettings(): array + { + return ['account_prefix' => 'demo', 'api_token' => 'secret-token']; + } + + /** + * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array} + */ + private function remoteInvoice(string $id, string $number): array + { + return [ + 'id' => $id, + 'number' => $number, + 'view_url' => 'https://demo.fakturownia.pl/invoices/' . $id, + 'pdf_url' => 'https://demo.fakturownia.pl/invoices/' . $id . '.pdf?api_token=secret-token', + 'raw' => ['id' => $id, 'number' => $number], + ]; + } + + private function externalFinalizeData(string $id, string $number): callable + { + return static function (array $data) use ($id, $number): bool { + return ($data['external_invoice_id'] ?? '') === $id + && ($data['invoice_number'] ?? '') === $number + && str_contains((string) ($data['external_pdf_url'] ?? ''), '/invoices/' . $id . '.pdf'); + }; + } + + private function createService(): InvoiceService + { + return new InvoiceService( + $this->invoices, + $this->invoiceConfigs, + $this->companySettings, + $this->orders, + $this->fakturownia, + $this->api + ); + } +}