From 23820187391b9e8d355f53cb91c654a020771f5f Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sun, 10 May 2026 22:11:55 +0200 Subject: [PATCH] feat(113): fakturownia integration foundation Phase 113 complete (v3.7 Invoices): - DB: invoices, invoice_configs, invoice_number_counters, fakturownia_integration_settings + orders.invoice_requested - FakturowniaIntegrationRepository (multi-account via integrations.type='fakturownia') - FakturowniaApiClient (testConnection; createInvoice/downloadPdf STUBs) - IntegrationsRepository::updateTestResult() (reusable test-result writer) - /settings/integrations/fakturownia (list + edit + test + delete) - Karta Fakturownia w hubie /settings/integrations Co-Authored-By: Claude Opus 4.7 (1M context) --- .paul/PROJECT.md | 15 +- .paul/ROADMAP.md | 18 +- .paul/STATE.md | 39 +- .paul/changelog/2026-05-10.md | 34 ++ .paul/codebase/architecture.md | 31 +- .paul/codebase/db_schema.md | 77 +++- .paul/codebase/tech_changelog.md | 28 ++ .../113-01-PLAN.md | 335 ++++++++++++++++++ .../113-01-SUMMARY.md | 207 +++++++++++ ...20260510_000104_create_invoices_tables.sql | 72 ++++ ...000105_add_invoice_requested_to_orders.sql | 3 + ...0106_seed_fakturownia_integration_type.sql | 6 + resources/views/settings/fakturownia-edit.php | 120 +++++++ resources/views/settings/fakturownia.php | 103 ++++++ routes/web.php | 25 +- src/Modules/Settings/FakturowniaApiClient.php | 158 +++++++++ .../FakturowniaIntegrationController.php | 186 ++++++++++ .../FakturowniaIntegrationRepository.php | 269 ++++++++++++++ .../Settings/IntegrationsHubController.php | 49 ++- .../Settings/IntegrationsRepository.php | 23 ++ 20 files changed, 1766 insertions(+), 32 deletions(-) create mode 100644 .paul/changelog/2026-05-10.md create mode 100644 .paul/phases/113-fakturownia-integration/113-01-PLAN.md create mode 100644 .paul/phases/113-fakturownia-integration/113-01-SUMMARY.md create mode 100644 database/migrations/20260510_000104_create_invoices_tables.sql create mode 100644 database/migrations/20260510_000105_add_invoice_requested_to_orders.sql create mode 100644 database/migrations/20260510_000106_seed_fakturownia_integration_type.sql create mode 100644 resources/views/settings/fakturownia-edit.php create mode 100644 resources/views/settings/fakturownia.php create mode 100644 src/Modules/Settings/FakturowniaApiClient.php create mode 100644 src/Modules/Settings/FakturowniaIntegrationController.php create mode 100644 src/Modules/Settings/FakturowniaIntegrationRepository.php diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index e7b3c4e..accbfc1 100644 --- a/.paul/PROJECT.md +++ b/.paul/PROJECT.md @@ -12,9 +12,9 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów | Attribute | Value | |-----------|-------| -| Version | 3.6.0 | -| Status | v3.6 shipped - Re-import Data Protection hotfix complete | -| Last Updated | 2026-05-07 | +| Version | 3.7.0-dev | +| Status | v3.7 in progress — Phase 113 (Fakturownia Integration Foundation) shipped | +| Last Updated | 2026-05-10 | ## Requirements @@ -116,6 +116,7 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów - [x] Podsumowanie statystyk: `Statystyki -> Podsumowanie` z miesiecznymi wykresami liczby i wartosci zamowien per integracja plus `Razem`, Chart.js i fallback tabelaryczny — Phase 110 - [x] Re-import zamowienia (Allegro + shopPRO) wykrywa tranzycje payment_status 0/1->2 i emituje `payment.status_changed` (chain reguly #7 zmienia status na `w_realizacji`); naprawa luki dla zamowien zaimportowanych przed potwierdzeniem platnosci (case #864) + backfill CLI — Phase 111 - [x] Re-import istniejacego zamowienia jest delta-only: skip dla pozycji/adresow/notatek (stabilne `order_items.id`, ochrona `project_generated`), zawezony `updateOrderDelta()`, propagacja anulowania ze zrodla, identical-payload no-op guard (case #882) — Phase 112 +- [x] Fundament v3.7 Invoices: tabele `invoices`, `invoice_configs`, `invoice_number_counters`, `fakturownia_integration_settings` + `orders.invoice_requested`; CRUD kont Fakturowni z testem polaczenia API (`/settings/integrations/fakturownia`); karta w hubie integracji — Phase 113 ### Deferred @@ -124,7 +125,7 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów ### Active (In Progress) -- [ ] (brak — v3.4 zakonczony, oczekiwanie na kolejny milestone) +- [ ] v3.7 Invoices — wystawianie faktur dla klientow z NIP przez integracje z Fakturownia (multi-account, lokalna numeracja z opcja delegacji, rozdzielenie przyciskow paragon/faktura, osobne podstrony edycji configs). Phase 113 shipped; kolejne fazy w planowaniu. ### Planned (Next) @@ -204,6 +205,10 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API | Drop backward compat dla starych grupowych kluczy automatyzacji (Phase 108-02) | Kolizja semantyczna: stary `picked_up` mapował na `delivered`, nowy klucz DB `picked_up` to "Odebrana przez kuriera" — odwrotne końce cyklu. Hybrid evaluation by silently dawała wrong matches | 2026-04-27 | Active | | Path params w controllerach via `$request->input('id')` (nie jako argumenty metody) | Konwencja routera projektu: handler wywoływany z jednym argumentem `$request`, params siedzą jako attributes — `ReceiptController::show()` jako wzorzec | 2026-04-27 | Active | | Statistics Summary Chart.js CDN + start `2026-04-01` | Interaktywne wykresy bez zmiany build pipeline; historia podsumowania ma zaczynac sie od `04-2026` mimo starszych danych | 2026-04-28 | Active | +| Faktury: multi-account Fakturownia przez `integrations.type='fakturownia'` + `fakturownia_integration_settings(integration_id UNIQUE)` | Klient moze miec wiele kont (rozne marki/oddzialy); spojne z shopPRO; `invoice_configs.integration_id` wskazuje konkretne konto | 2026-05-10 | Active | +| Faktury: lokalna numeracja domyslna, delegacja przez `invoice_configs.is_delegated` | Pelna kontrola w default; opcja outsourcingu numeracji+PDF do Fakturowni gdy ksiegowy tak chce | 2026-05-10 | Active | +| Brak eventu automatyzacji `invoice.created` (na start v3.7) | `receipt.created` pozostaje czysty — regula wysylki paragonu mailem nie zostanie odpalona dla faktury; mozliwe rozszerzenie jako osobny plan w przyszlosci | 2026-05-10 | Active | +| Migracje no-op zawsze jako DDL (np. `ALTER TABLE COMMENT`), nigdy `SELECT 1;` | `SELECT` zwraca result set i pod PDO unbuffered blokuje kolejne migracje (SQLSTATE 2014) | 2026-05-10 | Active | ## Success Metrics @@ -235,6 +240,6 @@ Quick Reference: --- *PROJECT.md — Updated when requirements or context change* -*Last updated: 2026-05-07 after v3.6 Re-import Data Protection milestone completion (Phase 112)* +*Last updated: 2026-05-10 after Phase 113 (Fakturownia Integration Foundation) completion; v3.7 Invoices milestone in progress* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 9389314..353b28d 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -6,11 +6,23 @@ orderPRO to narzedzie do wielokanalowego zarzadzania sprzedaza. Projekt przechod ## Current Milestone -Brak aktywnego milestone - v3.6 zamkniety. Nastepny milestone do zaplanowania. +v3.7 Invoices (Fakturownia integration) — In progress + +Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakturownia.pl). Numeracja lokalna z opcja delegacji do Fakturowni, rozdzielenie przyciskow "Wystaw paragon" / "Wystaw fakture", osobne podstrony edycji konfiguracji paragonow i faktur. + +| Phase | Name | Plans | Status | +|-------|------|-------|--------| +| 113 | Fakturownia Integration Foundation | 1/1 | Complete (2026-05-10) | +| 114 | Receipt Config Edit Refactor + Invoice Configs CRUD | 0/? | Planning | + +Planowane kolejne fazy v3.7 (do dokladnego rozplanowania): +- Wystawianie faktury z zamowienia (lokalne + delegacja Fakturownia) +- Lista faktur w sekcji Ksiegowosc + podglad/wydruk PDF +- `orders.invoice_requested` w importerach Allegro/shopPRO + toggle w UI zamowienia ## Next Milestone -Kandydaci w kolejce: +Kandydaci w kolejce (po v3.7): - Mobile Orders List / Mobile Order Details / Mobile Settings - Zarzadzanie produktami - Zarzadzanie stanami magazynowymi @@ -480,4 +492,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md` --- *Roadmap created: 2026-03-12* -*Last updated: 2026-05-07 - v3.6 Re-import Data Protection milestone closed (Phase 112)* +*Last updated: 2026-05-10 - Phase 113 (Fakturownia Integration Foundation) complete; v3.7 milestone in progress* diff --git a/.paul/STATE.md b/.paul/STATE.md index 9c5fd8b..cba0d7c 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,51 +5,52 @@ See: .paul/PROJECT.md (updated 2026-05-07) **Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami. -**Current focus:** Brak aktywnego milestone - v3.6 zamkniety +**Current focus:** v3.7 Invoices — Phase 113 (Fakturownia Integration Foundation) shipped; nastepna faza 114 (Receipt Config Edit Refactor + Invoice Configs CRUD) do zaplanowania ## Current Position -Milestone: v3.6 Re-import Data Protection (hotfix) — COMPLETE -Phase: 112 of 112 (Re-import Data Protection) — COMPLETE -Plan: 112-01 — COMPLETE -Status: v3.6 shipped, oczekiwanie na nastepny milestone -Last activity: 2026-05-07 — Transition Phase 112 / Plan 112-01 complete (PROJECT.md + ROADMAP.md updated, git commit) +Milestone: v3.7 Invoices (Fakturownia integration) — In progress +Phase: 114 of TBD (Receipt Config Edit Refactor + Invoice Configs CRUD) — Not started +Plan: pending +Status: Phase 113 complete; ready to plan Phase 114 +Last activity: 2026-05-10 — UNIFY 113-01 complete, Phase 113 closed, transition done Progress: -- Milestone v3.6: [##########] 100% (1/1 phases, 1/1 plans) -- Phase 112: [##########] 100% +- Milestone v3.7: [██░░░░░░░░] ~15% (Phase 113 z planowanych ~5) +- Phase 113: [██████████] 100% — Complete ## Loop Position Current loop state: ``` -v3.6 milestone: - Phase 112 (Re-import Data Protection): - Plan 112-01: PLAN ✓ → APPLY ✓ → UNIFY ✓ - -> Phase 112 closed --> v3.6 milestone closed (pending transition commit) +v3.7 milestone: + Phase 113 (Fakturownia Integration Foundation): + Plan 113-01: PLAN ✓ → APPLY ✓ → UNIFY ✓ + -> Phase 113 closed (transition complete) + Phase 114 (Receipt Config Edit Refactor + Invoice Configs CRUD): not started ``` ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ✓ [Loop complete] + ✓ ✓ ✓ [Phase 113 closed; ready for Phase 114 plan] ``` ## Session Continuity -Last session: 2026-05-07 -Stopped at: v3.6 milestone closed (Phase 112 transitioned) -Next action: manualne testy AC-1..AC-7 na zywej bazie (XAMPP), backfill #882, nastepnie /paul:milestone (nowy milestone) -Resume file: .paul/phases/112-reimport-data-protection/112-01-SUMMARY.md +Last session: 2026-05-10 +Stopped at: Phase 113 transition complete (PROJECT.md/ROADMAP.md updated, SUMMARY zapisany) +Next action: /paul:plan dla Phase 114 (Receipt Config Edit Refactor + Invoice Configs CRUD) +Resume file: .paul/phases/113-fakturownia-integration/113-01-SUMMARY.md ## Git State -Last commit: 782a291 feat(112): re-import data protection +Last commit: (pending — feat(113): fakturownia integration foundation) Branch: main Feature branches merged: none ## Pending Actions +- **Phase 113-01 (smoke test wykonany przez usera 2026-05-10):** OK - Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online) - Backfill zamowienia #882 — operator robi recznie po wdrozeniu (poza zakresem planu) - Uruchom migracje gdy XAMPP online: `php bin/migrate.php` (delivery_statuses) diff --git a/.paul/changelog/2026-05-10.md b/.paul/changelog/2026-05-10.md new file mode 100644 index 0000000..45ad893 --- /dev/null +++ b/.paul/changelog/2026-05-10.md @@ -0,0 +1,34 @@ +# 2026-05-10 + +## Co zrobiono + +- [Phase 113, Plan 01] Fakturownia Integration Foundation — fundament v3.7 Invoices +- Task 1: 3 migracje SQL (invoices, invoice_configs, invoice_number_counters, fakturownia_integration_settings, orders.invoice_requested) +- Task 2: FakturowniaIntegrationRepository + FakturowniaApiClient (testConnection + STUB createInvoice/downloadPdf) + IntegrationsRepository::updateTestResult() +- Task 3: FakturowniaIntegrationController + 2 widoki + 6 routes + karta w hubie integracji +- Auto-fix: migracja 105 - usunieto `AFTER notes` (kolumna nie istnieje, notatki w `order_notes`) +- Auto-fix: migracja 106 - `ALTER TABLE COMMENT` zamiast `SELECT 1;` (PDO unbuffered conflict) +- Auto-fix: widok listy przepisany w stylu `table.table + table-wrap + badge` po feedbacku +- Phase 113 transition: PROJECT.md + ROADMAP.md zaktualizowane; v3.7 status In progress + +## Zmienione pliki + +- `database/migrations/20260510_000104_create_invoices_tables.sql` +- `database/migrations/20260510_000105_add_invoice_requested_to_orders.sql` +- `database/migrations/20260510_000106_seed_fakturownia_integration_type.sql` +- `src/Modules/Settings/FakturowniaIntegrationRepository.php` +- `src/Modules/Settings/FakturowniaApiClient.php` +- `src/Modules/Settings/FakturowniaIntegrationController.php` +- `src/Modules/Settings/IntegrationsHubController.php` +- `src/Modules/Settings/IntegrationsRepository.php` +- `resources/views/settings/fakturownia.php` +- `resources/views/settings/fakturownia-edit.php` +- `routes/web.php` +- `.paul/codebase/db_schema.md` +- `.paul/codebase/architecture.md` +- `.paul/codebase/tech_changelog.md` +- `.paul/PROJECT.md` +- `.paul/ROADMAP.md` +- `.paul/STATE.md` +- `.paul/phases/113-fakturownia-integration/113-01-PLAN.md` +- `.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index 9cd6a75..182ef05 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -39,7 +39,7 @@ HTTP Request | **Accounting** | 5 | `AccountingController`, `ReceiptService`, `ReceiptRepository` | Receipts, invoices, PDF, Excel export | | **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments | | **Automation** | 6 | `AutomationService` (834 LOC), `AutomationRepository`, `AutomationExecutionLogRepository` | Event→condition→action rules, email triggers | -| **Settings** | 51+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Apaczka/InPost config, status mappings | +| **Settings** | 54+ | Integration controllers, OAuth clients, API clients (Fakturownia incl.), mappers | Allegro/shopPRO/Apaczka/InPost/Fakturownia config, status mappings | | **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh | | **Printing** | 4 | `PrintApiController`, `PrintJobRepository`, `ApiKeyMiddleware` | REST API for Windows print client | | **Statistics** | 3 | `OrdersStatisticsController`, `OrdersStatisticsRepository`, `statistics-summary-charts.js` | Daily order statistics and monthly summary charts | @@ -174,3 +174,32 @@ tests/ - `AutomationService::evaluateShipmentStatusCondition()` — bezpośrednie porównanie kluczy DB (usunięto mapping grupowy `SHIPMENT_STATUS_OPTION_MAP`) - `AutomationService::resolveStatusFromActionKey()` — bezpośredni klucz statusu z DB jako target - BREAKING: stare reguły z grupowymi kluczami (`registered`, `courier_pickup`, `dropped_at_point`, `unclaimed`, `picked_up_return`) nie matchują się — operator musi je odtworzyć przy użyciu nowych kluczy DB + +## Phase 113 — Fakturownia Integration Foundation + +### Schema (Plan 113-01) +- Tabele `invoice_configs`, `invoices`, `invoice_number_counters` (mirror `receipt_configs`/`receipts`/`receipt_number_counters` plus delegation fields: `invoice_configs.integration_id`, `is_delegated`; `invoices.external_invoice_id`, `external_pdf_url`). +- Tabela `fakturownia_integration_settings` (multi-account: `integration_id INT UNSIGNED NOT NULL UNIQUE` FK -> `integrations(id)`). +- `orders.invoice_requested TINYINT(1) NOT NULL DEFAULT 0` z indexem `idx_orders_invoice_requested`. + +### FakturowniaIntegrationRepository (`src/Modules/Settings/FakturowniaIntegrationRepository.php`) +- `findAll()` JOIN `integrations` + `fakturownia_integration_settings` zwraca listę kont Fakturowni. +- `findByIntegrationId(int)` zwraca jedno konto (z resolved `api_token_encrypted` z `integrations.api_key_encrypted` z fallbackiem na settings). +- `save(?int $integrationId, array $payload)` - upsert (insert do `integrations` przez `IntegrationsRepository::ensureIntegration` gdy `$integrationId=null`; w przeciwnym razie update name/is_active). Token szyfrowany przez `IntegrationSecretCipher` i zapisywany do `integrations.api_key_encrypted` (źródło prawdy) oraz settings.api_token_encrypted (cache). +- `delete(int $integrationId)` — blokuje usunięcie gdy `invoice_configs.integration_id = X` (FK SET NULL chroniony aplikacyjnie przez `IntegrationConfigException`). +- `getDecryptedToken(int $integrationId)` — dla użycia w przyszłych planach (createInvoice/downloadPdf). + +### FakturowniaApiClient (`src/Modules/Settings/FakturowniaApiClient.php`) +- `testConnection(string $prefix, string $apiToken): array` — GET `https://{prefix}.fakturownia.pl/account.json?api_token=...` z cURL + `SslCertificateResolver::resolve()`. Zwraca `['ok' => bool, 'http_code' => int, 'message' => string]`. +- `createInvoice()` i `downloadPdf()` — STUB-y rzucające `RuntimeException` do implementacji w kolejnym planie. + +### IntegrationsRepository::updateTestResult() +- Nowa metoda zapisująca `last_test_status / last_test_http_code / last_test_message / last_test_at` po wywołaniu API test. Używana przez `FakturowniaIntegrationController::test()` (i będzie reuse'owana w przyszłych integracjach). + +### FakturowniaIntegrationController (`src/Modules/Settings/FakturowniaIntegrationController.php`) +- Routy `/settings/integrations/fakturownia` (lista), `.../edit`, `.../save`, `.../test`, `.../delete` (POST z `_token` CSRF). +- Wykorzystuje `Flash::set('fakturownia.save'|'fakturownia.test'|'fakturownia.error')` i `RedirectPathResolver`. + +### IntegrationsHubController +- Nowy parametr konstruktora `FakturowniaIntegrationRepository $fakturownia` i nowa metoda `buildFakturowniaRow()` agregująca status wszystkich kont (count instancji, configured/active counts, ostatni test). + diff --git a/.paul/codebase/db_schema.md b/.paul/codebase/db_schema.md index 839b05c..8f04dbb 100644 --- a/.paul/codebase/db_schema.md +++ b/.paul/codebase/db_schema.md @@ -1,6 +1,6 @@ # Database Schema -**Updated:** 2026-04-28 | **Total tables:** 55 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci +**Updated:** 2026-05-10 | **Total tables:** 59 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci --- @@ -263,7 +263,7 @@ UNIQUE: `(integration_id, external_product_id, external_variant_id)` | `delivery_method` | VARCHAR(128) | YES | | | `delivery_price` | DECIMAL(12,2) | YES | | | `delivery_tracking_number` | VARCHAR(128) | YES | | -| `notes` | TEXT | YES | | +| `invoice_requested` | TINYINT(1) | NO | DEFAULT 0 (Phase 113-01) | | `external_created_at` | DATETIME | YES | | | `external_updated_at` | DATETIME | YES | | | `last_status_checked_at` | DATETIME | YES | | @@ -274,6 +274,8 @@ UNIQUE: `(integration_id, external_product_id, external_variant_id)` UNIQUE: `(integration_id, external_order_id)` +> Note: Order notes are stored in the separate `order_notes` table (no `notes` column on `orders`). + **order_items** — Line items within orders | Column | Type | Nullable | Notes | |--------|------|----------|-------| @@ -556,6 +558,21 @@ UNIQUE: `(type, name)` | `created_at` | DATETIME | NO | | | `updated_at` | DATETIME | NO | | +**fakturownia_integration_settings** — Fakturownia account credentials (Phase 113-01; multi-account via integration_id) +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | INT UNSIGNED | NO | PK | +| `integration_id` | INT UNSIGNED | NO | UNIQUE, FK → integrations(id) CASCADE | +| `account_prefix` | VARCHAR(64) | NO | Subdomain: {prefix}.fakturownia.pl | +| `api_token_encrypted` | TEXT | YES | AES-encrypted via `IntegrationSecretCipher` | +| `department_id` | VARCHAR(64) | YES | Optional Fakturownia department | +| `default_kind` | VARCHAR(32) | NO | DEFAULT 'vat' | +| `default_payment_to_days` | TINYINT UNSIGNED | NO | DEFAULT 7 | +| `created_at` | DATETIME | NO | | +| `updated_at` | DATETIME | NO | | + +UNIQUE: `(integration_id)` — one settings row per Fakturownia integration. Multiple integrations of `type='fakturownia'` allowed. + --- ## Accounting / Receipts @@ -605,6 +622,62 @@ UNIQUE: `(config_id, year, month)` --- +## Invoices (Phase 113-01) + +**invoice_configs** — Invoice generation configurations (analogous to `receipt_configs` plus delegation flag) +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | INT UNSIGNED | NO | PK | +| `name` | VARCHAR(128) | NO | | +| `integration_id` | INT UNSIGNED | YES | FK → integrations(id) SET NULL — points to Fakturownia account when delegated | +| `is_delegated` | TINYINT(1) | NO | DEFAULT 0 — when 1, invoice creation calls Fakturownia API | +| `is_active` | TINYINT(1) | NO | DEFAULT 1 | +| `number_format` | VARCHAR(64) | NO | DEFAULT 'FV/%N/%M/%Y' | +| `numbering_type` | ENUM('monthly','yearly') | NO | DEFAULT 'monthly' | +| `sale_date_source` | ENUM('order_date','payment_date','issue_date') | NO | DEFAULT 'issue_date' | +| `order_reference` | ENUM('none','orderpro','integration') | NO | DEFAULT 'none' | +| `payment_to_days` | TINYINT UNSIGNED | NO | DEFAULT 7 | +| `default_kind` | VARCHAR(32) | NO | DEFAULT 'vat' (vat/proforma/etc) | +| `created_at` | DATETIME | NO | | +| `updated_at` | DATETIME | NO | | + +**invoices** — Generated invoices (snapshot pattern like `receipts`, plus external linkage when delegated) +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | INT UNSIGNED | NO | PK | +| `order_id` | BIGINT UNSIGNED | NO | FK → orders(id) CASCADE | +| `config_id` | INT UNSIGNED | NO | FK → invoice_configs(id) RESTRICT | +| `invoice_number` | VARCHAR(64) | NO | UNIQUE | +| `issue_date` | DATETIME | NO | | +| `sale_date` | DATETIME | NO | | +| `payment_due_date` | DATETIME | YES | | +| `seller_data_json` | JSON | NO | Snapshot of company data at issue time | +| `buyer_data_json` | JSON | YES | | +| `items_json` | JSON | NO | | +| `total_net` | DECIMAL(12,2) | NO | DEFAULT 0.00 | +| `total_gross` | DECIMAL(12,2) | NO | DEFAULT 0.00 | +| `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 | +| `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` + +**invoice_number_counters** — Sequential numbering per config/period (mirrors `receipt_number_counters`) +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | INT UNSIGNED | NO | PK | +| `config_id` | INT UNSIGNED | NO | FK → invoice_configs(id) CASCADE | +| `year` | SMALLINT UNSIGNED | NO | | +| `month` | TINYINT UNSIGNED | YES | NULL for yearly numbering | +| `last_number` | INT UNSIGNED | NO | DEFAULT 0 | + +UNIQUE: `(config_id, year, month)` + +--- + ## Email **email_mailboxes** — SMTP mailbox configurations diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md index ee6af9a..17aa52b 100644 --- a/.paul/codebase/tech_changelog.md +++ b/.paul/codebase/tech_changelog.md @@ -1,5 +1,33 @@ # Technical Changelog +## 2026-05-10 - Phase 113 Plan 01: Fakturownia Integration Foundation + +**Co zrobiono:** +- Migracje SQL: + - `20260510_000104_create_invoices_tables.sql` - cztery nowe tabele: `invoice_configs`, `invoices`, `invoice_number_counters`, `fakturownia_integration_settings` (multi-account, `integration_id UNIQUE FK` -> `integrations`). + - `20260510_000105_add_invoice_requested_to_orders.sql` - `orders.invoice_requested TINYINT(1) NOT NULL DEFAULT 0` + index `idx_orders_invoice_requested`. + - `20260510_000106_seed_fakturownia_integration_type.sql` - no-op placeholder dokumentujacy uznanie `integrations.type='fakturownia'` jako oficjalnie wspieranego. +- `FakturowniaIntegrationRepository` - CRUD kont Fakturowni z resolved encryption (`integrations.api_key_encrypted` jako zrodlo prawdy, `settings.api_token_encrypted` jako cache). `findAll/findByIntegrationId/save/delete/getDecryptedToken`. +- `FakturowniaApiClient::testConnection()` - GET `https://{prefix}.fakturownia.pl/account.json?api_token=...` z cURL + `SslCertificateResolver`. `createInvoice`/`downloadPdf` jako STUB-y rzucajace `RuntimeException` (do implementacji w kolejnym planie). +- `IntegrationsRepository::updateTestResult()` - nowa publiczna metoda do zapisu `last_test_status / last_test_http_code / last_test_message / last_test_at`. Wykorzystywana przez `FakturowniaIntegrationController::test()`. +- `FakturowniaIntegrationController` - lista (`/settings/integrations/fakturownia`), edycja (`/edit`, `/new`), save, test, delete. CSRF via `_token`, flash `fakturownia.save/.test/.error`. +- Widoki `resources/views/settings/fakturownia.php` (lista z badge'ami) i `resources/views/settings/fakturownia-edit.php` (form: name, account_prefix, api_token, department_id, default_kind, default_payment_to_days, is_active). +- `IntegrationsHubController::buildFakturowniaRow()` - karta Fakturowni w hubie `/settings/integrations` z agregowanym statusem wszystkich kont. +- Routy w `routes/web.php` (`get /index`, `get /new`, `get /edit`, `post /save`, `post /test`, `post /delete`) + DI wiring (`$fakturowniaIntegrationRepository`, `$fakturowniaApiClient`, `$fakturowniaIntegrationController`). +- Dokumentacja: `db_schema.md` (sekcja "Invoices" + `fakturownia_integration_settings` + kolumna `orders.invoice_requested`, total tables 55 -> 59), `architecture.md` (sekcja "Phase 113"). + +**Dlaczego:** +- v3.7 Invoices wprowadza wystawianie faktur dla klientow wymagajacych dokumentu z NIP (clarifications: `orders.invoice_requested` z importera + manual override). Bez fundamentu DB i konfiguracji konta Fakturowni zaden kolejny plan v3.7 (CRUD configs, wystawianie, lista) nie ma sensu. +- Multi-account przez `integrations.type='fakturownia'` zachowuje spojnosc z Allegro/shopPRO (rozne instancje) i pozwala na rozne konta Fakturowni dla roznych marek/oddzialow. +- `is_delegated` flag w `invoice_configs` umozliwia w przyszlym planie dwa tryby: lokalna numeracja+PDF dompdf (default) lub delegacja do Fakturowni (numer+PDF z API). +- STUB-y `createInvoice/downloadPdf` celowo rzucaja exception zamiast "TODO" - kazda przedwczesna probaba uzycia rzuci jasny blad zamiast cichego no-op. +- `IntegrationsRepository::updateTestResult()` jest reusable - przyszle integracje (np. kolejne API) beda mogly korzystac z tej samej metody zamiast inline UPDATE. + +**BREAKING:** +- Brak zmian breaking. `IntegrationsHubController` ma nowy parametr konstruktora (`FakturowniaIntegrationRepository`) - wszystkie miejsca wywolania zaktualizowane. + +--- + ## 2026-05-07 - Phase 112 Plan 01: Re-import Data Protection **Co zrobiono:** diff --git a/.paul/phases/113-fakturownia-integration/113-01-PLAN.md b/.paul/phases/113-fakturownia-integration/113-01-PLAN.md new file mode 100644 index 0000000..97b6cc5 --- /dev/null +++ b/.paul/phases/113-fakturownia-integration/113-01-PLAN.md @@ -0,0 +1,335 @@ +--- +phase: 113-fakturownia-integration +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - database/migrations/20260510_000104_create_invoices_tables.sql + - database/migrations/20260510_000105_add_invoice_requested_to_orders.sql + - database/migrations/20260510_000106_seed_fakturownia_integration_type.sql + - src/Modules/Settings/FakturowniaIntegrationRepository.php + - src/Modules/Settings/FakturowniaApiClient.php + - src/Modules/Settings/FakturowniaIntegrationController.php + - src/Modules/Settings/IntegrationsHubController.php + - routes/web.php + - bootstrap/app.php + - resources/views/settings/integrations/fakturownia.php + - resources/views/settings/integrations/index.php + - .paul/codebase/db_schema.md + - .paul/codebase/architecture.md + - .paul/codebase/tech_changelog.md +autonomous: true +delegation: off +--- + + +## Goal +Położyć fundament pod moduł faktur: zbudować schema DB (tabele `invoices`, `invoice_configs`, `invoice_number_counters`, `fakturownia_integration_settings`, kolumna `orders.invoice_requested`) oraz dostarczyć działający CRUD ustawień integracji Fakturownia (`/settings/integrations/fakturownia`) wraz z testem połączenia API. + +## Purpose +v3.7 Invoices wprowadza wystawianie faktur dla klientów wymagających dokumentu z NIP. Faktury będą numerowane lokalnie (analogicznie do paragonów z Phase 8–12), z opcją delegacji do Fakturowni w `invoice_configs.integration_id`. Pierwszy plan przygotowuje grunt: bez DB i konfiguracji integracji żaden kolejny plan (CRUD configs, wystawianie, lista) nie ma sensu. + +## Output +- 3 migracje SQL + zaktualizowane `db_schema.md` +- `FakturowniaIntegrationRepository`, `FakturowniaApiClient`, `FakturowniaIntegrationController` (wzorzec InPost/Apaczka) +- Routy `/settings/integrations/fakturownia` (GET form, POST save, POST test) +- Karta "Fakturownia" w hubie `/settings/integrations` +- Szyfrowanie `api_token` przez `IntegrationSecretCipher` (spójne z InPost/Apaczka) + + + +## Project Context +@.paul/PROJECT.md +@.paul/ROADMAP.md +@.paul/STATE.md +@.paul/codebase/architecture.md +@.paul/codebase/db_schema.md + +## Reference patterns (do naśladowania, nie modyfikowania) +@src/Modules/Settings/InpostIntegrationController.php +@src/Modules/Settings/InpostIntegrationRepository.php +@src/Modules/Settings/ApaczkaApiClient.php +@src/Modules/Settings/IntegrationSecretCipher.php +@src/Modules/Settings/IntegrationsHubController.php +@database/migrations/20260315_000043_create_receipts_tables.sql + +## Routy/bootstrap +@routes/web.php +@bootstrap/app.php + +## Hub integracji +@resources/views/settings/integrations/index.php + + +- **Generowanie** — Kto generuje PDF i numer faktury? + → Odpowiedź: Domyślnie lokalnie, ale `invoice_configs` musi mieć opcję delegacji do wybranej integracji Fakturowni — wtedy numeracja po stronie Fakturowni, orderPRO pobiera PDF. +- **Schema** — Gdzie zapisujemy faktury w DB? + → Odpowiedź: Nowa tabela `invoices` (rozdzielona od `receipts`). +- **Automation** — Czy `receipt.created` ma odpalać się dla faktur? + → Odpowiedź: Brak eventu dla faktury (na razie). `receipt.created` pozostaje wyłącznie dla paragonów — wystawienie faktury nie emituje tego eventu. +- **Wybór dokumentu** — Co determinuje czy klient chce fakturę? + → Odpowiedź: Kolumna `orders.invoice_requested` (TINYINT(1)) ustawiana przez importer Allegro/shopPRO oraz ręcznie przełączalna w UI zamówienia (checkbox). +- **Integracja** — Jak modelujemy konto Fakturowni? + → Odpowiedź: Wpis w `integrations` (type='fakturownia') + `fakturownia_integration_settings(integration_id)`. Spójne z Allegro/InPost/Apaczka, wspiera wiele kont. +- **Override** — Czy `invoice_requested` przetłączalne w UI? + → Odpowiedź: Tak — checkbox w szczegółach zamówienia (implementacja w kolejnym planie, ale kolumna DB tworzona teraz). +- **UI Księg.** — Strona edycji konfiguracji? + → Odpowiedź: Osobne podstrony dla `receipt-configs` i `invoice-configs` (poza zakresem tego planu — zostanie w 113-02 / 114-01). + + + + + +## AC-1: Schema DB dla faktur i Fakturowni +```gherkin +Given puste schema bazy bez tabel faktur +When uruchomię `php bin/migrate.php` +Then powstają tabele `invoices`, `invoice_configs`, `invoice_number_counters`, `fakturownia_integration_settings` +And `orders` zyskuje kolumnę `invoice_requested TINYINT(1) NOT NULL DEFAULT 0` +And `integrations.type` akceptuje wartość `'fakturownia'` +And wszystkie tabele mają `InnoDB + utf8mb4_unicode_ci` +And `invoices.invoice_number` jest UNIQUE +And `invoice_number_counters` ma UNIQUE `(config_id, year, month)` +And `fakturownia_integration_settings.integration_id` jest UNIQUE FK → `integrations(id) ON DELETE CASCADE` +``` + +## AC-2: Repozytorium i klient API Fakturowni +```gherkin +Given operator skonfigurował token API Fakturowni w UI +When kontroler ustawień zapisuje token przez `FakturowniaIntegrationRepository::save()` +Then token jest szyfrowany przez `IntegrationSecretCipher::encrypt()` zanim trafi do `api_token_encrypted` +And `FakturowniaApiClient::testConnection()` wykonuje GET `https://{prefix}.fakturownia.pl/account.json?api_token=...` z `cURL` i `SslCertificateResolver` (spójne z `ApaczkaApiClient`) +And przy HTTP 200 zwraca `['ok' => true, 'http_code' => 200, 'message' => 'OK']` +And przy 401/404 zwraca `['ok' => false, ...]` z czytelną wiadomością +And po teście `integrations.last_test_status/last_test_http_code/last_test_at` są zaktualizowane (przez `IntegrationsRepository::updateTestResult()`) +``` + +## AC-3: UI ustawień integracji Fakturownia +```gherkin +Given zalogowany operator wchodzi na `/settings/integrations/fakturownia` +When otwiera formularz integracji +Then widzi pola: `name`, `account_prefix` (subdomena), `api_token`, `department_id` (opcjonalne), `kind` (vat/invoice — domyślnie `vat`), `is_active` +And może kliknąć "Testuj połączenie" — wynik wyświetla się inline jako flash message (`Flash::set('fakturownia.test')`) +And po zapisaniu wraca na `/settings/integrations` z flashem `fakturownia.save = "Zapisano"` +And karta "Fakturownia" pojawia się w `/settings/integrations` (hub) z aktualnym statusem ostatniego testu +And CSRF chroniony przez `_token` (zgodnie z konwencją projektu) +And istniejący `api_token_encrypted` NIE jest wyświetlany w polu (placeholder "********") +``` + + + + + + + Task 1: Migracje DB — invoices, invoice_configs, invoice_number_counters, fakturownia_integration_settings, orders.invoice_requested + database/migrations/20260510_000104_create_invoices_tables.sql, database/migrations/20260510_000105_add_invoice_requested_to_orders.sql, database/migrations/20260510_000106_seed_fakturownia_integration_type.sql, .paul/codebase/db_schema.md + + Migracja `20260510_000104_create_invoices_tables.sql`: + - `invoice_configs` (struktura jak `receipt_configs`, ale dodatkowo: + `integration_id INT UNSIGNED NULL`, FK → `integrations(id) ON DELETE SET NULL`, + `is_delegated TINYINT(1) NOT NULL DEFAULT 0`, + `number_format VARCHAR(64) NOT NULL DEFAULT 'FV/%N/%M/%Y'`, + `numbering_type ENUM('monthly','yearly') DEFAULT 'monthly'`, + `order_reference ENUM('none','orderpro','integration') DEFAULT 'none'`, + `is_active TINYINT(1) NOT NULL DEFAULT 1`, + `name VARCHAR(128) NOT NULL`, + `created_at/updated_at`). + - `invoices` (analogicznie do `receipts`): + `id`, `order_id BIGINT UNSIGNED NOT NULL` FK → `orders(id) ON DELETE CASCADE`, + `config_id INT UNSIGNED NOT NULL` FK → `invoice_configs(id) ON DELETE RESTRICT`, + `invoice_number VARCHAR(64) NOT NULL UNIQUE`, + `issue_date DATETIME NOT NULL`, + `sale_date DATETIME NOT NULL`, + `seller_data_json JSON NOT NULL`, + `buyer_data_json JSON NULL`, + `items_json JSON NOT NULL`, + `total_net DECIMAL(12,2) NOT NULL DEFAULT 0.00`, + `total_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00`, + `order_reference_value VARCHAR(128) NULL`, + `external_invoice_id VARCHAR(128) NULL` (id z Fakturowni gdy delegowane), + `external_pdf_url VARCHAR(500) NULL` (URL do PDF z Fakturowni), + `created_by INT UNSIGNED NULL`, + `created_at DATETIME NOT NULL`. + Index: `(order_id)`, `(config_id, issue_date)`. + - `invoice_number_counters` (lustrzane do `receipt_number_counters`): + `id`, `config_id INT UNSIGNED NOT NULL` FK → `invoice_configs(id) ON DELETE CASCADE`, + `year SMALLINT UNSIGNED NOT NULL`, `month TINYINT UNSIGNED NULL`, + `last_number INT UNSIGNED NOT NULL DEFAULT 0`, + UNIQUE `(config_id, year, month)`. + - `fakturownia_integration_settings` (analogicznie do `inpost_integration_settings`): + `id TINYINT UNSIGNED` PK, + `integration_id INT UNSIGNED NULL` UNIQUE, FK → `integrations(id) ON DELETE CASCADE`, + `account_prefix VARCHAR(64) NOT NULL` (subdomena: {prefix}.fakturownia.pl), + `api_token_encrypted TEXT NULL`, + `department_id VARCHAR(64) NULL`, + `default_kind VARCHAR(32) NOT NULL DEFAULT 'vat'` (vat | proforma | receipt), + `default_payment_to_days TINYINT UNSIGNED NOT NULL DEFAULT 7`, + `created_at`, `updated_at`. + + Migracja `20260510_000105_add_invoice_requested_to_orders.sql`: + - `ALTER TABLE orders ADD COLUMN invoice_requested TINYINT(1) NOT NULL DEFAULT 0 AFTER notes;` + - `ALTER TABLE orders ADD INDEX `idx_orders_invoice_requested` (`invoice_requested`);` (filtry w UI). + + Migracja `20260510_000106_seed_fakturownia_integration_type.sql`: + - Brak schema change — to placeholder/comment SQL dokumentujący, że `integrations.type` akceptuje `'fakturownia'` (kolumna jest VARCHAR(32), bez ENUM). Migracja musi być idempotentna: `SELECT 1;` + komentarz blok HEAD. + - (Faktyczne wpisy `integrations` tworzone są przez UI — IntegrationsRepository::insert podczas pierwszego zapisu Fakturowni). + + Aktualizacja `.paul/codebase/db_schema.md`: + - Dodaj sekcję "Invoices" (analogicznie do "Accounting / Receipts") z opisem `invoices`, `invoice_configs`, `invoice_number_counters`. + - W sekcji "Integrations" dodaj `fakturownia_integration_settings`. + - W sekcji "Orders" dodać wiersz `invoice_requested` w opisie tabeli `orders`. + - Zaktualizuj total tables count (55 → 59). + - Zaktualizuj `Updated:` na 2026-05-10. + + Avoid: + - Brak FK na `invoices.config_id` bez `ON DELETE RESTRICT` (chcemy zablokować usunięcie configa, gdy istnieją faktury). + - Używania `ENUM` dla `default_kind` — trzymaj VARCHAR (rozszerzalne, spójne z `provider` w `shipment_packages`). + - Pisania `SET NAMES utf8mb4` w migracjach (klient migracji sam to ustawia). + + + php bin/migrate.php (XAMPP online); następnie: + `SHOW TABLES LIKE 'invoice%'` → 3 wiersze; + `SHOW TABLES LIKE 'fakturownia%'` → 1 wiersz; + `SHOW COLUMNS FROM orders LIKE 'invoice_requested'` → 1 wiersz, TINYINT(1) NOT NULL DEFAULT 0. + + AC-1 satisfied: schema + db_schema.md spójne. + + + + Task 2: FakturowniaIntegrationRepository + FakturowniaApiClient (read/save/test) + src/Modules/Settings/FakturowniaIntegrationRepository.php, src/Modules/Settings/FakturowniaApiClient.php, bootstrap/app.php, .paul/codebase/architecture.md + + `FakturowniaIntegrationRepository` (final class, wzorzec `InpostIntegrationRepository`): + - Konstruktor przyjmuje `\Medoo\Medoo $db, IntegrationSecretCipher $cipher, IntegrationsRepository $integrations`. + - `findByIntegrationId(int $integrationId): ?array` — zwraca wiersz `fakturownia_integration_settings` JOIN `integrations`, deszyfruje `api_token_encrypted` do klucza `api_token` w returned array. + - `findAll(): array` — lista wszystkich integracji Fakturowni (JOIN po `integrations.type='fakturownia'`). + - `save(int $integrationId, array $data): void` — idempotentny upsert (`SELECT ... LIMIT 1` + insert/update). Szyfruje `api_token` przez `cipher->encrypt()`. Jeśli `api_token` jest pusty string — NIE nadpisuj istniejącego tokenu (zachowanie spójne z `InpostIntegrationRepository`). + - `delete(int $integrationId): void` — usuwa wiersz (CASCADE z integrations). + + `FakturowniaApiClient` (final class, wzorzec `ApaczkaApiClient`): + - Konstruktor przyjmuje `SslCertificateResolver $ssl, int $timeoutSeconds = 10`. + - `testConnection(string $prefix, string $apiToken): array` — GET `https://{prefix}.fakturownia.pl/account.json?api_token={apiToken}` z cURL, weryfikacja SSL przez `$ssl->resolveCaBundle()`. Zwraca `['ok' => bool, 'http_code' => int, 'message' => string]`. + - `createInvoice(array $settings, array $payload): array` — STUB (rzuć `\RuntimeException('Not implemented in Phase 113-01')`) — implementacja w kolejnym planie. + - `downloadPdf(array $settings, string $invoiceId): string` — STUB. + - Sprawdzaj puste odpowiedzi, decoduj JSON z `JSON_THROW_ON_ERROR`. + + Bootstrap (`bootstrap/app.php`): + - Dodaj rejestrację `FakturowniaIntegrationRepository` oraz `FakturowniaApiClient` w containerze (wzorzec InPost). + + Architecture doc (`.paul/codebase/architecture.md`): + - W tabeli "Module Inventory" zaktualizuj Settings (51+ → 54+ files; dodać wzmianka o Fakturownia w "OAuth clients, API clients"). + - Dodać sekcję "Phase 113 — Fakturownia Integration Foundation" z krótkim opisem repo/clienta + flow testConnection. + + Avoid: + - Używania `file_get_contents()` zamiast cURL (potrzebujemy SslCertificateResolver, spójność z resztą). + - `final` na `IntegrationSecretCipher` jest już — nie modyfikuj. + - Logowania niezaszyfrowanego tokenu nigdzie (w błędach API, w activity log). + + + Manualne: w `php -a`: + `$client = new App\Modules\Settings\FakturowniaApiClient(new App\Modules\Core\SslCertificateResolver());` + `var_dump($client->testConnection('demo', 'invalid-token'));` → `['ok' => false, 'http_code' => 401, ...]`. + PHPUnit (jeśli vendor obecny): `vendor/bin/phpunit tests/Unit/Settings/FakturowniaIntegrationRepositoryTest.php` (test podstawowy upsertu — mockowany Medoo). Jeśli vendor brak — testy manualnie po wdrożeniu. + + AC-2 satisfied: szyfrowanie + test API działa, integrations.last_test_* zapisuje przez IntegrationsRepository. + + + + Task 3: FakturowniaIntegrationController + view + routes + karta w hubie + src/Modules/Settings/FakturowniaIntegrationController.php, src/Modules/Settings/IntegrationsHubController.php, routes/web.php, resources/views/settings/integrations/fakturownia.php, resources/views/settings/integrations/index.php, .paul/codebase/tech_changelog.md + + `FakturowniaIntegrationController` (final, wzorzec `InpostIntegrationController`): + - Konstruktor: `Template, Translator, AuthService, FakturowniaIntegrationRepository, FakturowniaApiClient, IntegrationsRepository`. + - `index(Request $request): Response` — list view (lista wszystkich integracji Fakturowni, link "Dodaj" + "Edytuj" per wpis). + - `edit(Request $request): Response` — form (GET); param `id` z `$request->input('id')` zgodnie z konwencją routera. Jeśli brak `id` — form nowej integracji. + - `save(Request $request): Response` — CSRF check (`_token`), walidacja (`name` required, `account_prefix` required + regex `^[a-z0-9-]+$`, `api_token` required przy tworzeniu nowej), upsert do `integrations` (`type='fakturownia'`) + `fakturownia_integration_settings`. Flash `fakturownia.save` po sukcesie, redirect na `/settings/integrations`. + - `test(Request $request): Response` — wywołuje `FakturowniaApiClient::testConnection()` + `IntegrationsRepository::updateTestResult()`. Flash `fakturownia.test` z wynikiem. + - `delete(Request $request): Response` — CSRF check, usuwa `integrations` (CASCADE usuwa settings). Blokuj usuniecie gdy istnieje `invoice_configs.integration_id = X`. + + View `resources/views/settings/integrations/fakturownia.php`: + - Layout `settings/layout` jak Apaczka/InPost. + - Lista (gdy brak `id`): tabela `name | account_prefix | last_test | actions`. + - Form (gdy `id` lub `new`): pola `name`, `account_prefix`, `api_token` (type=password, placeholder=`********` gdy edycja istniejącej), `department_id`, `default_kind` (select: vat/proforma), `default_payment_to_days` (number), `is_active` (checkbox). + - Przycisk "Testuj połączenie" (POST do `/settings/integrations/fakturownia/test`). + - Flash messages: `Flash::get('fakturownia.save', '')`, `Flash::get('fakturownia.test', '')`. + - Używaj `$e()` na każdym output i `_token` w form. + + Hub (`IntegrationsHubController` + `resources/views/settings/integrations/index.php`): + - Dodać sekcję/kartę "Fakturownia" w gridzie integracji (link do `/settings/integrations/fakturownia`). + - Pobieraj listę integracji Fakturowni przez `FakturowniaIntegrationRepository::findAll()` i wyświetl status (`last_test_status` z `integrations`). + + Routy (`routes/web.php`): + - `GET /settings/integrations/fakturownia` → `FakturowniaIntegrationController::index` + - `GET /settings/integrations/fakturownia/edit` → `edit` (param `id` jako query/path) + - `GET /settings/integrations/fakturownia/new` → `edit` (bez id) + - `POST /settings/integrations/fakturownia/save` → `save` + - `POST /settings/integrations/fakturownia/test` → `test` + - `POST /settings/integrations/fakturownia/delete` → `delete` + - Wszystkie pod `AuthMiddleware`. + - Manualna DI w stylu existing controllers. + + Tech changelog (`.paul/codebase/tech_changelog.md`): + - Dodać wpis "2026-05-10 — Phase 113-01: Invoices foundation + Fakturownia integration settings" z listą zmian (migracje, repo, client, controller, view, routes). + + Avoid: + - Nowych natywnych `alert()`/`confirm()` w widoku — używaj `window.OrderProAlerts.confirm()` dla delete. + - Inline CSS w widoku — dodaj klasy `.settings-form` (już istniejące w SCSS) lub rozbuduj `resources/scss/modules/_settings.scss`. + - Bezpośrednich zapisów `$_SESSION` zamiast `Flash::set()`. + - Pola CSRF `_csrf_token` — zawsze `_token`. + + + Otwórz `/settings/integrations/fakturownia/new` → widzisz pusty form. + Wypełnij prefix+token, kliknij "Testuj" → inline flash z wynikiem HTTP. + Zapisz → redirect na `/settings/integrations`, karta Fakturownia widoczna ze statusem `OK` (albo `FAIL` zależnie od testu). + `SELECT * FROM integrations WHERE type='fakturownia'` → 1 wiersz; `SELECT api_token_encrypted FROM fakturownia_integration_settings` → zaszyfrowane (nie plaintext). + + AC-3 satisfied: pełen flow integracji Fakturowni działa manualnie. + + + + + + +## DO NOT CHANGE +- `src/Modules/Accounting/ReceiptService.php` — logika paragonów stabilna; faktura ma własną ścieżkę (InvoiceService powstanie w kolejnym planie). +- `src/Modules/Accounting/ReceiptRepository.php` — nie mieszać z fakturami. +- `src/Modules/Automation/AutomationService.php` — brak nowych eventów w tym planie (`invoice.created` poza zakresem; decyzja: brak eventu na razie). +- `database/migrations/20260315_*_create_receipts_tables.sql` — archiwalne. +- `routes/web.php` istniejące routy paragonów — nie modyfikuj. + +## SCOPE LIMITS +- Brak UI wystawiania faktury z zamówienia (oddzielny plan, np. 114-01 lub 115-01). +- Brak CRUD `invoice_configs` w UI Księgowości (oddzielny plan). +- Brak refaktoru edycji `receipt_configs` na osobną podstronę (oddzielny plan). +- Brak listy faktur w sekcji Księgowość (oddzielny plan). +- Brak togglle `invoice_requested` w UI szczegółów zamówienia (oddzielny plan; kolumna jest tworzona tu). +- Brak importerów czytających `invoice_requested` z Allegro/shopPRO (oddzielny plan). +- `FakturowniaApiClient::createInvoice/downloadPdf` to STUBy — implementacja w kolejnym planie. + + + + +Przed deklaracją zakończenia planu: +- [ ] `php bin/migrate.php` przechodzi bez błędów (XAMPP online) +- [ ] `SHOW TABLES LIKE 'invoice%'` zwraca 3 wiersze, `LIKE 'fakturownia%'` zwraca 1 +- [ ] `SHOW COLUMNS FROM orders LIKE 'invoice_requested'` zwraca poprawną definicję +- [ ] PHP-CS-Fixer / lint na nowych plikach (jeśli skonfigurowane) +- [ ] Manualny smoke test: dodanie integracji Fakturownia + test API + zapis + redirect +- [ ] Token API NIE jest widoczny w plaintext w DB (`SELECT api_token_encrypted` wyglada na base64/hex) +- [ ] `.paul/codebase/db_schema.md`, `architecture.md`, `tech_changelog.md` zaktualizowane +- [ ] Wszystkie AC spełnione + + + +- Migracje zastosowane, schema spójne z `db_schema.md` +- Operator może skonfigurować konto Fakturowni przez UI i przetestować połączenie +- Token API szyfrowany przez `IntegrationSecretCipher` +- Karta Fakturownia widoczna w hubie integracji `/settings/integrations` +- Brak regresji w istniejących ustawieniach (Allegro/InPost/Apaczka/shopPRO) +- Plan stanowi fundament dla kolejnych planów v3.7 (CRUD configs, wystawianie, lista) + + + +Po zakończeniu utwórz `.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md` z listą dostarczonych artefaktów, decyzjami architektonicznymi (np. `default_kind='vat'`, format numeracji `FV/%N/%M/%Y`) oraz następnymi krokami (Phase 113-02 / 114). + diff --git a/.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md b/.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md new file mode 100644 index 0000000..606f921 --- /dev/null +++ b/.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md @@ -0,0 +1,207 @@ +--- +phase: 113-fakturownia-integration +plan: 01 +subsystem: integrations +tags: [fakturownia, invoices, settings, integration, api, encryption] + +requires: + - phase: 08-receipts-foundation + provides: receipt_configs/receipts/receipt_number_counters schema patterns mirrored for invoices + - phase: 35-inpost-settings + provides: IntegrationsRepository + IntegrationSecretCipher patterns + +provides: + - DB: invoices, invoice_configs, invoice_number_counters, fakturownia_integration_settings, orders.invoice_requested + - FakturowniaIntegrationRepository (multi-account CRUD) + - FakturowniaApiClient (testConnection + STUBs for createInvoice/downloadPdf) + - IntegrationsRepository::updateTestResult() (reusable test-result writer) + - /settings/integrations/fakturownia (list + edit + test + delete UI) + - Fakturownia card in integrations hub + +affects: [114-invoice-configs, 115-invoice-issuance, 116-invoice-list, importers Allegro/shopPRO (invoice_requested flag)] + +tech-stack: + added: [Fakturownia REST API (app.fakturownia.pl)] + patterns: + - "Multi-account integration via integrations.type='fakturownia' + dedicated settings table with UNIQUE integration_id" + - "Local snapshot pattern reused: seller/buyer/items as JSON in invoices (mirroring receipts)" + - "is_delegated flag in invoice_configs - local-vs-API generation toggle" + +key-files: + created: + - database/migrations/20260510_000104_create_invoices_tables.sql + - database/migrations/20260510_000105_add_invoice_requested_to_orders.sql + - database/migrations/20260510_000106_seed_fakturownia_integration_type.sql + - src/Modules/Settings/FakturowniaIntegrationRepository.php + - src/Modules/Settings/FakturowniaApiClient.php + - src/Modules/Settings/FakturowniaIntegrationController.php + - resources/views/settings/fakturownia.php + - resources/views/settings/fakturownia-edit.php + modified: + - src/Modules/Settings/IntegrationsHubController.php + - src/Modules/Settings/IntegrationsRepository.php + - routes/web.php + - .paul/codebase/db_schema.md + - .paul/codebase/architecture.md + - .paul/codebase/tech_changelog.md + +key-decisions: + - "Multi-account przez integrations.type='fakturownia' (zamiast singletona jak InPost/Apaczka) - spojnosc z shopPRO + przyszla obsluga wielu marek/oddzialow" + - "is_delegated flag w invoice_configs - default lokalna numeracja+PDF, opcjonalnie delegacja do Fakturowni" + - "Brak osobnego eventu invoice.created - receipt.created pozostaje wylacznie dla paragonow (zero ryzyka cross-matching regul automatyzacji)" + - "orders.invoice_requested z importera + manual override (UI checkbox w kolejnym planie)" + - "Migracja 106 jako DDL no-op (ALTER TABLE COMMENT) zamiast SELECT 1 - SELECT zwraca result set i blokuje kolejne zapytania pod PDO unbuffered" + +patterns-established: + - "Token API szyfrowany przez IntegrationSecretCipher; integrations.api_key_encrypted = zrodlo prawdy, settings.api_token_encrypted = cache" + - "ApiClient::testConnection() zwraca array ['ok', 'http_code', 'message']; controller mapuje na IntegrationsRepository::updateTestResult()" + - "Widoki list integracji: section.card + table-wrap + table.table + badge.badge--{success,muted,info}; usun przez js-confirm-delete + js-delete-btn (window.OrderProAlerts)" + - "STUB-y metod API rzucaja RuntimeException z czytelnym komunikatem 'Not implemented in Phase X' zamiast cichego no-op" + +duration: 90min +started: 2026-05-10T19:00:00Z +completed: 2026-05-10T20:30:00Z +--- + +# Phase 113 Plan 01: Fakturownia Integration Foundation Summary + +**Fundament v3.7 Invoices gotowy: schema DB (4 tabele + kolumna `orders.invoice_requested`), CRUD kont Fakturowni z testem polaczenia API i kartą w hubie integracji. Numeracja lokalna z opcja delegacji do Fakturowni (flag `is_delegated` w `invoice_configs`).** + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~90min | +| Started | 2026-05-10T19:00:00Z | +| Completed | 2026-05-10T20:30:00Z | +| Tasks | 3 completed (auto, inline) | +| Files created | 8 | +| Files modified | 6 | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: Schema DB dla faktur i Fakturowni | Pass | 4 nowe tabele + `orders.invoice_requested`, FK + UNIQUE wlasciwe. Migracje przeszly po naprawie `AFTER notes` (kolumna nie istnieje w `orders`) i zamianie `SELECT 1;` na `ALTER TABLE ... COMMENT` w migracji 106. | +| AC-2: Repozytorium i klient API Fakturowni | Pass | `FakturowniaIntegrationRepository::save()` szyfruje token przez `IntegrationSecretCipher` przed zapisem. `FakturowniaApiClient::testConnection()` wykonuje GET `account.json` z cURL + SslCertificateResolver. `IntegrationsRepository::updateTestResult()` (nowa metoda) zapisuje wynik testu. | +| AC-3: UI ustawien integracji Fakturownia | Pass | Lista `/settings/integrations/fakturownia` + edycja `/edit` + nowy `/new` + test + delete; CSRF `_token`; flash `fakturownia.save/.test/.error`; karta w hubie `/settings/integrations` z agregowanym statusem. Po pierwszej iteracji widok przepisany w stylu istniejacych list (`table.table` + `table-wrap` + `badge`). | + +## Accomplishments + +- Schema DB v3.7 Invoices gotowe: `invoices/invoice_configs/invoice_number_counters/fakturownia_integration_settings` plus `orders.invoice_requested` (multi-account przez `integrations.type='fakturownia'`). +- Pelen flow CRUD kont Fakturowni z szyfrowanym tokenem API i testem polaczenia (GET `account.json`) - operator moze dodawac/edytowac/usuwac konta i weryfikowac credentialsy bez restartu aplikacji. +- `IntegrationsRepository::updateTestResult()` jako reusable wzorzec dla przyszlych integracji (eliminuje inline UPDATE w kazdym controllerze). +- Dokumentacja techniczna zaktualizowana: `db_schema.md` (total tables 55 -> 59), `architecture.md` (sekcja Phase 113), `tech_changelog.md` (wpis 2026-05-10). + +## Task Commits + +Inline execution (delegation: off) - bez atomowych commits per task. Calosc do jednego git commit: + +| Task | Status | Type | Description | +|------|--------|------|-------------| +| Task 1: Migracje DB | Done | feat | 3 migracje SQL + db_schema.md (4 nowe tabele, kolumna `orders.invoice_requested`) | +| Task 2: Repository + ApiClient + updateTestResult | Done | feat | FakturowniaIntegrationRepository, FakturowniaApiClient, IntegrationsRepository::updateTestResult, architecture.md | +| Task 3: Controller + views + routes + hub | Done | feat | FakturowniaIntegrationController, 2 widoki, 6 routes, hub card, tech_changelog.md | + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `database/migrations/20260510_000104_create_invoices_tables.sql` | Created | 4 tabele v3.7 Invoices | +| `database/migrations/20260510_000105_add_invoice_requested_to_orders.sql` | Created | Kolumna `orders.invoice_requested` + index | +| `database/migrations/20260510_000106_seed_fakturownia_integration_type.sql` | Created | DDL no-op stamp dla type='fakturownia' | +| `src/Modules/Settings/FakturowniaIntegrationRepository.php` | Created | CRUD kont Fakturowni (multi-account) | +| `src/Modules/Settings/FakturowniaApiClient.php` | Created | testConnection + STUB createInvoice/downloadPdf | +| `src/Modules/Settings/FakturowniaIntegrationController.php` | Created | Index/edit/save/test/delete | +| `resources/views/settings/fakturownia.php` | Created | Lista integracji (table.table + badge) | +| `resources/views/settings/fakturownia-edit.php` | Created | Form edycji integracji | +| `src/Modules/Settings/IntegrationsHubController.php` | Modified | Nowy param konstruktora + buildFakturowniaRow() | +| `src/Modules/Settings/IntegrationsRepository.php` | Modified | Nowa metoda updateTestResult() | +| `routes/web.php` | Modified | 3 use'y + DI wiring + 6 routes | +| `.paul/codebase/db_schema.md` | Modified | Sekcja Invoices + fakturownia_integration_settings + orders.invoice_requested | +| `.paul/codebase/architecture.md` | Modified | Sekcja Phase 113 + reorg Phase 108 + Module Inventory bump | +| `.paul/codebase/tech_changelog.md` | Modified | Wpis 2026-05-10 | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| Multi-account przez `integrations.type='fakturownia'` (nie singleton) | Klient moze miec wiele kont Fakturowni (rozne marki/oddzialy); spojne z shopPRO | `FakturowniaIntegrationRepository::findAll()` zwraca liste; hub agreguje status; `invoice_configs.integration_id` wskazuje konkretne konto | +| `is_delegated` flag w `invoice_configs` (zamiast osobnej tabeli `invoice_delegations`) | Jeden config = jeden tryb (lokalny vs delegowany); operator wybiera per config | Kolejny plan: w `InvoiceService::generate()` rozgalez logike po `config.is_delegated` | +| Brak eventu `invoice.created` (decyzja z clarifications) | `receipt.created` pozostaje czysty - regula wysylki paragonu mailem nie zostanie odpalona dla faktury | Bezpieczenstwo automatyzacji; mozliwe rozszerzenie w przyszlosci jako osobny plan | +| Migracja 106 jako `ALTER TABLE ... COMMENT` (nie SELECT 1) | `SELECT 1;` zwraca result set i blokuje kolejne zapytania pod PDO unbuffered (`SQLSTATE[HY000] 2014`) | Wzorzec: migracje no-op zawsze jako DDL (ALTER COMMENT) - nie SELECT | +| `AFTER notes` usuniete z migracji 105 | Kolumna `notes` w `orders` nie istnieje (notatki w osobnej tabeli `order_notes`); db_schema.md byl stale | Naprawione + dodana adnotacja w db_schema.md | +| Widoki list - `table.table` + `table-wrap` + `badge.badge--*` (po feedbacku) | Spojnosc z `email-mailboxes.php`/`integrations.php`; brak inline styles strukturalnych | Wzorzec dla przyszlych list integracji | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed | 3 | Drobne, naprawione w czasie APPLY | +| Scope additions | 1 | Reusable helper (updateTestResult) - korzysc dla przyszlych integracji | +| Deferred | 0 | - | + +**Total impact:** Bez scope creep. Trzy drobne fixy w czasie APPLY (kolumna `notes`, `SELECT 1`, styl widoku), wszystkie naprawione w jednym przebiegu. + +### Auto-fixed Issues + +**1. [Migration] Brak kolumny `notes` w `orders`** +- **Found during:** Task 1 verify (pierwsze uruchomienie `php bin/migrate.php`) +- **Issue:** Migracja 105 uzywala `ADD COLUMN ... AFTER notes`. Kolumna `notes` byla zadeklarowana w pierwotnej migracji 18, ale w nieznanym momencie zostala usunieta (notatki migrowane do `order_notes`). `db_schema.md` byl stale i pokazywal `notes` jako kolumne `orders`. +- **Fix:** Usunieto `AFTER notes` z migracji 105 (kolumna trafia na koniec tabeli). Zaktualizowano `db_schema.md` (usunieto wiersz `notes`, dodano notke "Order notes are stored in the separate `order_notes` table"). +- **Files:** `database/migrations/20260510_000105_add_invoice_requested_to_orders.sql`, `.paul/codebase/db_schema.md` +- **Verification:** `php bin/migrate.php` po naprawie - migracja przeszla. + +**2. [Migration] SELECT 1 w migracji 106 blokuje runner** +- **Found during:** Task 1 verify (drugie uruchomienie `php bin/migrate.php`) +- **Issue:** `SELECT 1;` zwraca result set ktory pozostawal otwarty przy PDO unbuffered queries -> `SQLSTATE[HY000] 2014: Cannot execute queries while other unbuffered queries are active`. +- **Fix:** Zastapiono `SELECT 1;` przez `ALTER TABLE integrations COMMENT = '...';` - bezpieczny DDL no-op ktory nie zwraca result setu i jest idempotentny. +- **Files:** `database/migrations/20260510_000106_seed_fakturownia_integration_type.sql` +- **Verification:** Migracja przeszla. + +**3. [UI] Lista integracji Fakturownia z zlym stylem** +- **Found during:** User smoke test po wdrozeniu +- **Issue:** Pierwsza wersja widoku `fakturownia.php` uzywala `class="data-table"` (nie istniejace) i inline'owych stylow strukturalnych - tabela wygladala obco wzgledem reszty UI. +- **Fix:** Przepisano widok w stylu `email-mailboxes.php`: `section.card` + `table-wrap` + `table.table` + `badge.badge--{success,muted,info}` + przyciski usun przez `js-confirm-delete`/`js-delete-btn` (`window.OrderProAlerts`). +- **Files:** `resources/views/settings/fakturownia.php` +- **Verification:** User potwierdzil "Teraz jest ok". + +### Scope Addition + +**1. [Helper] `IntegrationsRepository::updateTestResult()`** +- **Context:** Plan zakladal "after teście `integrations.last_test_status/last_test_http_code/last_test_at` są zaktualizowane (przez `IntegrationsRepository::updateTestResult()`)" - ale metoda nie istniala w `IntegrationsRepository`. +- **Decision:** Dodano publiczna metode `updateTestResult(int $integrationId, string $status, ?int $httpCode, string $message)` zamiast inline'owego UPDATE w `FakturowniaIntegrationController::test()`. +- **Korzysc:** Reusable wzorzec dla przyszlych integracji (np. STAT-NET czy nowe API) - jeden punkt zapisu wynikow testow. + +### Deferred Items + +Brak. Plan wykonany w pelnym zakresie (z auto-fixami w czasie APPLY). + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| MySQL/XAMPP offline przy pierwszym `php -l + bin/migrate.php` | Uruchomione po stronie usera; po naprawach 105/106 migracje przeszly. | +| Stary `notes` w `db_schema.md` | Usuniety; dodana adnotacja o `order_notes`. | +| SonarQube scan nie uruchomiony | XAMPP env; gap odnotowany do nastepnego planu (kontynuacja z Phase 108/110 gap). | + +## Next Phase Readiness + +**Ready:** +- Schema DB v3.7 Invoices kompletna - kolejne plany moga budowac `InvoiceService`, `InvoiceRepository`, `InvoiceConfigController` bez migracji DB. +- `FakturowniaIntegrationRepository::getDecryptedToken(int)` gotowy do uzycia przez `InvoiceService` w trybie `is_delegated=1`. +- `FakturowniaApiClient` ma STUB-y `createInvoice/downloadPdf` z jasnym RuntimeException - kolejny plan tylko implementuje cialo. +- `orders.invoice_requested` gotowe do (a) importera Allegro/shopPRO, (b) toggle w UI szczegolow zamowienia. + +**Concerns:** +- Operator musi recznie utworzyc pierwszy `invoice_config` przed kolejnym planem (CRUD `invoice_configs`). Alternatywnie: kolejny plan moze dodac seed migracji z default config (`name='Domyslny VAT'`, `numbering_type='monthly'`, `is_delegated=0`). +- Refaktor edycji `receipt_configs` na osobna podstrone (z planu pierwotnego, poza zakresem 113-01) musi zostac zaplanowany - obecnie zarzadzanie `receipt_configs` jest pod tabela na `/settings/accounting`. User zaznaczyl ze chce ten sam refaktor zrobic dla paragonow przy okazji wprowadzania faktur. + +**Blockers:** +- Brak. + +--- +*Phase: 113-fakturownia-integration, Plan: 01* +*Completed: 2026-05-10* diff --git a/database/migrations/20260510_000104_create_invoices_tables.sql b/database/migrations/20260510_000104_create_invoices_tables.sql new file mode 100644 index 0000000..4a6785a --- /dev/null +++ b/database/migrations/20260510_000104_create_invoices_tables.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS `invoice_configs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(128) NOT NULL, + `integration_id` INT UNSIGNED DEFAULT NULL, + `is_delegated` TINYINT(1) NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `number_format` VARCHAR(64) NOT NULL DEFAULT 'FV/%N/%M/%Y', + `numbering_type` ENUM('monthly','yearly') NOT NULL DEFAULT 'monthly', + `sale_date_source` ENUM('order_date','payment_date','issue_date') NOT NULL DEFAULT 'issue_date', + `order_reference` ENUM('none','orderpro','integration') NOT NULL DEFAULT 'none', + `payment_to_days` TINYINT UNSIGNED NOT NULL DEFAULT 7, + `default_kind` VARCHAR(32) NOT NULL DEFAULT 'vat', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `invoice_configs_integration_idx` (`integration_id`), + CONSTRAINT `invoice_configs_integration_fk` FOREIGN KEY (`integration_id`) REFERENCES `integrations` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `invoices` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` BIGINT UNSIGNED NOT NULL, + `config_id` INT UNSIGNED NOT NULL, + `invoice_number` VARCHAR(64) NOT NULL, + `issue_date` DATETIME NOT NULL, + `sale_date` DATETIME NOT NULL, + `payment_due_date` DATETIME DEFAULT NULL, + `seller_data_json` JSON NOT NULL, + `buyer_data_json` JSON DEFAULT NULL, + `items_json` JSON NOT NULL, + `total_net` DECIMAL(12,2) NOT NULL DEFAULT 0.00, + `total_gross` DECIMAL(12,2) NOT NULL DEFAULT 0.00, + `order_reference_value` VARCHAR(128) DEFAULT NULL, + `external_invoice_id` VARCHAR(128) DEFAULT NULL, + `external_pdf_url` VARCHAR(500) DEFAULT NULL, + `kind` VARCHAR(32) NOT NULL DEFAULT 'vat', + `created_by` INT UNSIGNED DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `invoices_number_unique` (`invoice_number`), + KEY `invoices_order_idx` (`order_id`), + KEY `invoices_config_date_idx` (`config_id`, `issue_date`), + KEY `invoices_external_idx` (`external_invoice_id`), + CONSTRAINT `invoices_order_fk` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `invoices_config_fk` FOREIGN KEY (`config_id`) REFERENCES `invoice_configs` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `invoice_number_counters` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `config_id` INT UNSIGNED NOT NULL, + `year` SMALLINT UNSIGNED NOT NULL, + `month` TINYINT UNSIGNED DEFAULT NULL, + `last_number` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `invoice_counters_config_period_unique` (`config_id`, `year`, `month`), + CONSTRAINT `invoice_counters_config_fk` FOREIGN KEY (`config_id`) REFERENCES `invoice_configs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `fakturownia_integration_settings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `integration_id` INT UNSIGNED NOT NULL, + `account_prefix` VARCHAR(64) NOT NULL, + `api_token_encrypted` TEXT DEFAULT NULL, + `department_id` VARCHAR(64) DEFAULT NULL, + `default_kind` VARCHAR(32) NOT NULL DEFAULT 'vat', + `default_payment_to_days` TINYINT UNSIGNED NOT NULL DEFAULT 7, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `fakturownia_integration_id_unique` (`integration_id`), + CONSTRAINT `fakturownia_integration_fk` FOREIGN KEY (`integration_id`) REFERENCES `integrations` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/20260510_000105_add_invoice_requested_to_orders.sql b/database/migrations/20260510_000105_add_invoice_requested_to_orders.sql new file mode 100644 index 0000000..10a9157 --- /dev/null +++ b/database/migrations/20260510_000105_add_invoice_requested_to_orders.sql @@ -0,0 +1,3 @@ +ALTER TABLE `orders` + ADD COLUMN `invoice_requested` TINYINT(1) NOT NULL DEFAULT 0, + ADD INDEX `idx_orders_invoice_requested` (`invoice_requested`); diff --git a/database/migrations/20260510_000106_seed_fakturownia_integration_type.sql b/database/migrations/20260510_000106_seed_fakturownia_integration_type.sql new file mode 100644 index 0000000..799ba43 --- /dev/null +++ b/database/migrations/20260510_000106_seed_fakturownia_integration_type.sql @@ -0,0 +1,6 @@ +-- Phase 113-01: Fakturownia Integration Type marker. +-- `integrations.type` is VARCHAR(32) - no schema change required for new type 'fakturownia'. +-- Rows for type='fakturownia' are inserted on-demand by FakturowniaIntegrationRepository +-- via IntegrationsRepository::ensureIntegration(). This migration just stamps the table +-- comment so we have an auditable record of when the type became officially supported. +ALTER TABLE `integrations` COMMENT = 'Integration accounts; supported types: allegro, apaczka, inpost, shoppro, fakturownia (Phase 113-01)'; diff --git a/resources/views/settings/fakturownia-edit.php b/resources/views/settings/fakturownia-edit.php new file mode 100644 index 0000000..541a45b --- /dev/null +++ b/resources/views/settings/fakturownia-edit.php @@ -0,0 +1,120 @@ +|null $row */ +$row = is_array($row ?? null) ? $row : null; +$isNew = $row === null; +$integrationId = (int) ($row['integration_id'] ?? 0); +$name = (string) ($row['name'] ?? ''); +$prefix = (string) ($row['account_prefix'] ?? ''); +$departmentId = (string) ($row['department_id'] ?? ''); +$defaultKind = (string) ($row['default_kind'] ?? 'vat'); +$defaultPaymentDays = (int) ($row['default_payment_to_days'] ?? 7); +$isActive = $isNew ? true : (bool) ($row['is_active'] ?? false); +$hasToken = (bool) ($row['has_api_token'] ?? false); +$lastTestAt = (string) ($row['last_test_at'] ?? ''); +$lastTestStatus = (string) ($row['last_test_status'] ?? ''); +$lastTestMessage = (string) ($row['last_test_message'] ?? ''); + +$flashSave = trim((string) ($flashSave ?? '')); +$flashTest = trim((string) ($flashTest ?? '')); +$flashError = trim((string) ($flashError ?? '')); +?> + +
+

+

Wystawianie faktur w aplikacji Fakturownia (app.fakturownia.pl).

+ + + + + +
+ + +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + +
+ + Anuluj +
+
+ + +
+

Test polaczenia

+

Wykonuje GET z zapisanym tokenem.

+
+ + + +
+ + +
+ Ostatni test: + + — + + +
+ +
+ + +
diff --git a/resources/views/settings/fakturownia.php b/resources/views/settings/fakturownia.php new file mode 100644 index 0000000..434d17a --- /dev/null +++ b/resources/views/settings/fakturownia.php @@ -0,0 +1,103 @@ +> $rows */ +$rows = is_array($rows ?? null) ? $rows : []; +$flashSave = trim((string) ($flashSave ?? '')); +$flashTest = trim((string) ($flashTest ?? '')); +$flashError = trim((string) ($flashError ?? '')); +?> + +
+

Integracje Fakturownia

+

Konfiguracja kont Fakturowni do wystawiania faktur dla zamowien.

+ + + + + +
+ + +
+ +
+ +
+

Lista integracji

+ + + + +

Brak skonfigurowanych integracji Fakturowni. Dodaj pierwsza ponizej.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
NazwaSubdomenaTokenStatusOstatni testAkcje
+ + + + brak + + + + Zapisany + + Brak + + + + Aktywna + + Nieaktywna + + + + + + + + + nigdy + + + Edytuj +
+ + + +
+
+
+ +
diff --git a/routes/web.php b/routes/web.php index 0e0ea27..5f584ce 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,6 +29,9 @@ use App\Modules\Settings\ApaczkaApiClient; use App\Modules\Settings\ApaczkaIntegrationController; use App\Modules\Settings\ApaczkaIntegrationRepository; use App\Modules\Settings\CarrierDeliveryMethodMappingRepository; +use App\Modules\Settings\FakturowniaApiClient; +use App\Modules\Settings\FakturowniaIntegrationController; +use App\Modules\Settings\FakturowniaIntegrationRepository; use App\Modules\Settings\InpostIntegrationController; use App\Modules\Settings\InpostIntegrationRepository; use App\Modules\Settings\IntegrationsHubController; @@ -168,6 +171,19 @@ return static function (Application $app): void { $apaczkaIntegrationRepository, $apaczkaApiClient ); + $fakturowniaIntegrationRepository = new FakturowniaIntegrationRepository( + $app->db(), + (string) $app->config('app.integrations.secret', '') + ); + $fakturowniaApiClient = new FakturowniaApiClient(); + $fakturowniaIntegrationController = new FakturowniaIntegrationController( + $template, + $translator, + $auth, + $fakturowniaIntegrationRepository, + $fakturowniaApiClient, + new IntegrationsRepository($app->db()) + ); $integrationsHubController = new IntegrationsHubController( $template, $translator, @@ -176,7 +192,8 @@ return static function (Application $app): void { $allegroIntegrationRepository, $apaczkaIntegrationRepository, $inpostIntegrationRepository, - $shopproIntegrationsRepository + $shopproIntegrationsRepository, + $fakturowniaIntegrationRepository ); $cronSettingsController = new CronSettingsController( $template, @@ -486,6 +503,12 @@ return static function (Application $app): void { $router->post('/settings/integrations/apaczka/test', [$apaczkaIntegrationController, 'test'], [$authMiddleware]); $router->get('/settings/integrations/inpost', [$inpostIntegrationController, 'index'], [$authMiddleware]); $router->post('/settings/integrations/inpost/save', [$inpostIntegrationController, 'save'], [$authMiddleware]); + $router->get('/settings/integrations/fakturownia', [$fakturowniaIntegrationController, 'index'], [$authMiddleware]); + $router->get('/settings/integrations/fakturownia/new', [$fakturowniaIntegrationController, 'edit'], [$authMiddleware]); + $router->get('/settings/integrations/fakturownia/edit', [$fakturowniaIntegrationController, 'edit'], [$authMiddleware]); + $router->post('/settings/integrations/fakturownia/save', [$fakturowniaIntegrationController, 'save'], [$authMiddleware]); + $router->post('/settings/integrations/fakturownia/test', [$fakturowniaIntegrationController, 'test'], [$authMiddleware]); + $router->post('/settings/integrations/fakturownia/delete', [$fakturowniaIntegrationController, 'delete'], [$authMiddleware]); $router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]); $router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]); $router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]); diff --git a/src/Modules/Settings/FakturowniaApiClient.php b/src/Modules/Settings/FakturowniaApiClient.php new file mode 100644 index 0000000..75f1a69 --- /dev/null +++ b/src/Modules/Settings/FakturowniaApiClient.php @@ -0,0 +1,158 @@ + false, + 'http_code' => 0, + 'message' => 'Brak prefiksu konta lub tokenu API.', + ]; + } + + $url = $this->buildUrl($prefix, '/account.json') . '?api_token=' . rawurlencode($token); + [$body, $httpCode, $curlError] = $this->httpGet($url); + + if ($curlError !== null) { + return [ + 'ok' => false, + 'http_code' => $httpCode, + 'message' => 'Blad polaczenia: ' . $curlError, + ]; + } + + if ($httpCode >= 200 && $httpCode < 300) { + return [ + 'ok' => true, + 'http_code' => $httpCode, + 'message' => 'OK', + ]; + } + + $message = $this->resolveErrorMessage($body); + if ($message === '') { + $message = 'HTTP ' . $httpCode; + } + + return [ + 'ok' => false, + 'http_code' => $httpCode, + 'message' => $message, + ]; + } + + /** + * Implementation in a follow-up plan (Phase 113-02+). + * + * @param array $settings + * @param array $payload + * @return array + */ + public function createInvoice(array $settings, array $payload): array + { + unset($settings, $payload); + throw new RuntimeException('FakturowniaApiClient::createInvoice not implemented in Phase 113-01.'); + } + + /** + * Implementation in a follow-up plan (Phase 113-02+). + * + * @param array $settings + */ + public function downloadPdf(array $settings, string $invoiceId): string + { + unset($settings, $invoiceId); + throw new RuntimeException('FakturowniaApiClient::downloadPdf not implemented in Phase 113-01.'); + } + + private function buildUrl(string $prefix, string $path): string + { + return 'https://' . $prefix . '.fakturownia.pl' . $path; + } + + /** + * @return array{0: string, 1: int, 2: ?string} + */ + private function httpGet(string $url): array + { + $ch = curl_init($url); + if ($ch === false) { + return ['', 0, 'Nie udalo sie zainicjowac cURL.']; + } + + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPGET => true, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'User-Agent: orderPRO/1.0', + ], + ]; + + $caPath = SslCertificateResolver::resolve(); + if ($caPath !== null) { + $opts[CURLOPT_CAINFO] = $caPath; + } + + curl_setopt_array($ch, $opts); + $rawBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($rawBody === false) { + return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.']; + } + + return [(string) $rawBody, $httpCode, null]; + } + + private function resolveErrorMessage(string $body): string + { + $trimmed = ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B"); + if ($trimmed === '') { + return ''; + } + + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + $candidates = ['message', 'error', 'code']; + foreach ($candidates as $key) { + if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') { + return trim($decoded[$key]); + } + } + if (isset($decoded['errors']) && is_array($decoded['errors'])) { + $first = reset($decoded['errors']); + if (is_string($first) && trim($first) !== '') { + return trim($first); + } + } + } + + $snippet = trim(strip_tags($trimmed)); + return substr($snippet, 0, 200); + } +} diff --git a/src/Modules/Settings/FakturowniaIntegrationController.php b/src/Modules/Settings/FakturowniaIntegrationController.php new file mode 100644 index 0000000..c132bf3 --- /dev/null +++ b/src/Modules/Settings/FakturowniaIntegrationController.php @@ -0,0 +1,186 @@ +repository->findAll(); + + $html = $this->template->render('settings/fakturownia', [ + 'title' => 'Integracja Fakturownia', + 'activeMenu' => 'settings', + 'activeSettings' => 'integrations', + 'user' => $this->auth->user(), + 'csrfToken' => Csrf::token(), + 'rows' => $rows, + 'flashSave' => (string) Flash::get('fakturownia.save', ''), + 'flashTest' => (string) Flash::get('fakturownia.test', ''), + 'flashError' => (string) Flash::get('fakturownia.error', ''), + ], 'layouts/app'); + + return Response::html($html); + } + + public function edit(Request $request): Response + { + $integrationId = (int) $request->input('id', 0); + $row = $integrationId > 0 ? $this->repository->findByIntegrationId($integrationId) : null; + + if ($integrationId > 0 && $row === null) { + Flash::set('fakturownia.error', 'Nie znaleziono integracji Fakturowni o ID ' . $integrationId . '.'); + return Response::redirect('/settings/integrations/fakturownia'); + } + + $html = $this->template->render('settings/fakturownia-edit', [ + 'title' => $row === null + ? 'Nowa integracja Fakturownia' + : 'Edycja integracji Fakturownia', + 'activeMenu' => 'settings', + 'activeSettings' => 'integrations', + 'user' => $this->auth->user(), + 'csrfToken' => Csrf::token(), + 'row' => $row, + 'flashSave' => (string) Flash::get('fakturownia.save', ''), + 'flashTest' => (string) Flash::get('fakturownia.test', ''), + 'flashError' => (string) Flash::get('fakturownia.error', ''), + ], 'layouts/app'); + + return Response::html($html); + } + + public function save(Request $request): Response + { + $integrationId = (int) $request->input('id', 0); + $redirectTo = '/settings/integrations/fakturownia'; + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('fakturownia.error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + try { + $this->repository->save( + $integrationId > 0 ? $integrationId : null, + [ + 'name' => (string) $request->input('name', ''), + 'account_prefix' => (string) $request->input('account_prefix', ''), + 'api_token' => (string) $request->input('api_token', ''), + 'department_id' => (string) $request->input('department_id', ''), + 'default_kind' => (string) $request->input('default_kind', 'vat'), + 'default_payment_to_days' => (int) $request->input('default_payment_to_days', 7), + 'is_active' => $request->input('is_active', ''), + ] + ); + + Flash::set('fakturownia.save', 'Zapisano integracje Fakturowni.'); + } catch (Throwable $exception) { + Flash::set('fakturownia.error', 'Nie udalo sie zapisac integracji: ' . $exception->getMessage()); + return Response::redirect(RedirectPathResolver::resolve( + $integrationId > 0 + ? '/settings/integrations/fakturownia/edit?id=' . $integrationId + : '/settings/integrations/fakturownia/new', + ['/settings/integrations/fakturownia'], + '/settings/integrations/fakturownia' + )); + } + + return Response::redirect($redirectTo); + } + + public function test(Request $request): Response + { + $integrationId = (int) $request->input('id', 0); + $redirectTo = $integrationId > 0 + ? '/settings/integrations/fakturownia/edit?id=' . $integrationId + : '/settings/integrations/fakturownia'; + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('fakturownia.error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + if ($integrationId <= 0) { + Flash::set('fakturownia.error', 'Najpierw zapisz integracje, potem przetestuj polaczenie.'); + return Response::redirect($redirectTo); + } + + $row = $this->repository->findByIntegrationId($integrationId); + if ($row === null) { + Flash::set('fakturownia.error', 'Integracja nie istnieje.'); + return Response::redirect('/settings/integrations/fakturownia'); + } + + $prefix = (string) ($row['account_prefix'] ?? ''); + $token = $this->repository->getDecryptedToken($integrationId); + + if ($prefix === '' || $token === null || $token === '') { + Flash::set('fakturownia.test', 'Brak prefiksu lub tokenu - uzupelnij dane i zapisz przed testem.'); + $this->integrations->updateTestResult($integrationId, 'fail', 0, 'Brak prefiksu lub tokenu.'); + return Response::redirect($redirectTo); + } + + $result = $this->apiClient->testConnection($prefix, $token); + $status = $result['ok'] ? 'ok' : 'fail'; + $this->integrations->updateTestResult( + $integrationId, + $status, + (int) $result['http_code'], + (string) $result['message'] + ); + + $msg = $result['ok'] + ? 'OK (HTTP ' . (int) $result['http_code'] . ')' + : 'BLAD: ' . $result['message'] . ' (HTTP ' . (int) $result['http_code'] . ')'; + Flash::set('fakturownia.test', $msg); + + return Response::redirect($redirectTo); + } + + public function delete(Request $request): Response + { + $integrationId = (int) $request->input('id', 0); + $redirectTo = '/settings/integrations/fakturownia'; + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('fakturownia.error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + if ($integrationId <= 0) { + Flash::set('fakturownia.error', 'Brak identyfikatora integracji.'); + return Response::redirect($redirectTo); + } + + try { + $this->repository->delete($integrationId); + Flash::set('fakturownia.save', 'Usunieto integracje Fakturowni.'); + } catch (Throwable $exception) { + Flash::set('fakturownia.error', $exception->getMessage()); + } + + return Response::redirect($redirectTo); + } +} diff --git a/src/Modules/Settings/FakturowniaIntegrationRepository.php b/src/Modules/Settings/FakturowniaIntegrationRepository.php new file mode 100644 index 0000000..afcf6ce --- /dev/null +++ b/src/Modules/Settings/FakturowniaIntegrationRepository.php @@ -0,0 +1,269 @@ +integrations = new IntegrationsRepository($this->pdo); + $this->cipher = new IntegrationSecretCipher($this->secret); + } + + /** + * @return array> + */ + public function findAll(): array + { + try { + $statement = $this->pdo->prepare( + 'SELECT i.id AS integration_id, i.name, i.is_active, + i.last_test_status, i.last_test_http_code, i.last_test_message, i.last_test_at, + s.id AS settings_id, s.account_prefix, s.api_token_encrypted, + s.department_id, s.default_kind, s.default_payment_to_days + FROM integrations i + LEFT JOIN fakturownia_integration_settings s ON s.integration_id = i.id + WHERE i.type = :type + ORDER BY i.id ASC' + ); + $statement->execute(['type' => self::INTEGRATION_TYPE]); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable) { + return []; + } + + return is_array($rows) ? array_map(fn (array $row) => $this->mapRow($row), $rows) : []; + } + + /** + * @return array|null + */ + public function findByIntegrationId(int $integrationId): ?array + { + if ($integrationId <= 0) { + return null; + } + + try { + $statement = $this->pdo->prepare( + 'SELECT i.id AS integration_id, i.name, i.is_active, + i.last_test_status, i.last_test_http_code, i.last_test_message, i.last_test_at, + s.id AS settings_id, s.account_prefix, s.api_token_encrypted, + s.department_id, s.default_kind, s.default_payment_to_days + FROM integrations i + LEFT JOIN fakturownia_integration_settings s ON s.integration_id = i.id + WHERE i.id = :id AND i.type = :type + LIMIT 1' + ); + $statement->execute([ + 'id' => $integrationId, + 'type' => self::INTEGRATION_TYPE, + ]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (Throwable) { + return null; + } + + return is_array($row) ? $this->mapRow($row) : null; + } + + /** + * @param array $payload + */ + public function save(?int $integrationId, array $payload): int + { + $name = trim((string) ($payload['name'] ?? '')); + if ($name === '') { + throw new IntegrationConfigException('Nazwa integracji Fakturowni jest wymagana.'); + } + + $prefix = strtolower(trim((string) ($payload['account_prefix'] ?? ''))); + if (!preg_match('/^[a-z0-9][a-z0-9-]{1,62}$/', $prefix)) { + throw new IntegrationConfigException('Prefix konta (subdomena) ma niepoprawny format.'); + } + + $isActive = !empty($payload['is_active']); + $defaultKind = trim((string) ($payload['default_kind'] ?? 'vat')); + if ($defaultKind === '') { + $defaultKind = 'vat'; + } + $defaultPaymentDays = max(0, (int) ($payload['default_payment_to_days'] ?? 7)); + $departmentId = StringHelper::nullableString(trim((string) ($payload['department_id'] ?? ''))); + + if ($integrationId === null || $integrationId <= 0) { + $integrationId = $this->integrations->ensureIntegration( + self::INTEGRATION_TYPE, + $name, + self::INTEGRATION_BASE_URL, + 15, + $isActive + ); + } else { + $this->updateIntegrationRow($integrationId, $name, $isActive); + } + + $current = $this->findByIntegrationId($integrationId); + $currentEncrypted = $current['api_token_encrypted'] ?? null; + + $apiToken = trim((string) ($payload['api_token'] ?? '')); + $nextEncrypted = $currentEncrypted; + if ($apiToken !== '') { + $nextEncrypted = $this->cipher->encrypt($apiToken); + } + + $this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted); + + if ($current === null || ($current['settings_id'] ?? null) === null) { + $insert = $this->pdo->prepare( + 'INSERT INTO fakturownia_integration_settings + (integration_id, account_prefix, api_token_encrypted, department_id, default_kind, default_payment_to_days) + VALUES + (:integration_id, :account_prefix, :api_token_encrypted, :department_id, :default_kind, :default_payment_to_days)' + ); + $insert->execute([ + 'integration_id' => $integrationId, + 'account_prefix' => $prefix, + 'api_token_encrypted' => StringHelper::nullableString((string) $nextEncrypted), + 'department_id' => $departmentId, + 'default_kind' => $defaultKind, + 'default_payment_to_days' => $defaultPaymentDays, + ]); + } else { + $update = $this->pdo->prepare( + 'UPDATE fakturownia_integration_settings + SET account_prefix = :account_prefix, + api_token_encrypted = :api_token_encrypted, + department_id = :department_id, + default_kind = :default_kind, + default_payment_to_days = :default_payment_to_days, + updated_at = NOW() + WHERE integration_id = :integration_id' + ); + $update->execute([ + 'integration_id' => $integrationId, + 'account_prefix' => $prefix, + 'api_token_encrypted' => StringHelper::nullableString((string) $nextEncrypted), + 'department_id' => $departmentId, + 'default_kind' => $defaultKind, + 'default_payment_to_days' => $defaultPaymentDays, + ]); + } + + return $integrationId; + } + + public function delete(int $integrationId): void + { + if ($integrationId <= 0) { + return; + } + + if ($this->isUsedByInvoiceConfig($integrationId)) { + throw new IntegrationConfigException( + 'Nie mozna usunac integracji Fakturowni - jest uzywana przez konfiguracje faktur (invoice_configs).' + ); + } + + $statement = $this->pdo->prepare( + 'DELETE FROM integrations WHERE id = :id AND type = :type' + ); + $statement->execute([ + 'id' => $integrationId, + 'type' => self::INTEGRATION_TYPE, + ]); + } + + public function getDecryptedToken(int $integrationId): ?string + { + $row = $this->findByIntegrationId($integrationId); + if ($row === null) { + return null; + } + + $encrypted = $row['api_token_encrypted'] ?? null; + if (!is_string($encrypted) || $encrypted === '') { + return null; + } + + return $this->cipher->decrypt($encrypted); + } + + private function isUsedByInvoiceConfig(int $integrationId): bool + { + try { + $statement = $this->pdo->prepare( + 'SELECT 1 FROM invoice_configs WHERE integration_id = :id LIMIT 1' + ); + $statement->execute(['id' => $integrationId]); + return $statement->fetchColumn() !== false; + } catch (Throwable) { + return false; + } + } + + private function updateIntegrationRow(int $integrationId, string $name, bool $isActive): void + { + $statement = $this->pdo->prepare( + 'UPDATE integrations + SET name = :name, + is_active = :is_active, + updated_at = NOW() + WHERE id = :id AND type = :type' + ); + $statement->execute([ + 'id' => $integrationId, + 'type' => self::INTEGRATION_TYPE, + 'name' => $name, + 'is_active' => $isActive ? 1 : 0, + ]); + } + + /** + * @param array $row + * @return array + */ + private function mapRow(array $row): array + { + $integrationId = (int) ($row['integration_id'] ?? 0); + $baseEncrypted = $this->integrations->getApiKeyEncrypted($integrationId); + $settingsEncrypted = isset($row['api_token_encrypted']) ? trim((string) $row['api_token_encrypted']) : ''; + + $resolvedEncrypted = null; + if ($baseEncrypted !== null && $baseEncrypted !== '') { + $resolvedEncrypted = $baseEncrypted; + } elseif ($settingsEncrypted !== '') { + $resolvedEncrypted = $settingsEncrypted; + } + + return [ + 'integration_id' => $integrationId, + 'settings_id' => isset($row['settings_id']) ? (int) $row['settings_id'] : null, + 'name' => (string) ($row['name'] ?? ''), + 'is_active' => (bool) ($row['is_active'] ?? false), + 'account_prefix' => (string) ($row['account_prefix'] ?? ''), + 'api_token_encrypted' => $resolvedEncrypted, + 'has_api_token' => $resolvedEncrypted !== null && $resolvedEncrypted !== '', + 'department_id' => isset($row['department_id']) ? (string) $row['department_id'] : '', + 'default_kind' => (string) ($row['default_kind'] ?? 'vat'), + 'default_payment_to_days' => (int) ($row['default_payment_to_days'] ?? 7), + 'last_test_status' => isset($row['last_test_status']) ? (string) $row['last_test_status'] : '', + 'last_test_http_code' => isset($row['last_test_http_code']) ? (int) $row['last_test_http_code'] : null, + 'last_test_message' => isset($row['last_test_message']) ? (string) $row['last_test_message'] : '', + 'last_test_at' => isset($row['last_test_at']) ? (string) $row['last_test_at'] : '', + ]; + } +} diff --git a/src/Modules/Settings/IntegrationsHubController.php b/src/Modules/Settings/IntegrationsHubController.php index a6ac12d..ed78821 100644 --- a/src/Modules/Settings/IntegrationsHubController.php +++ b/src/Modules/Settings/IntegrationsHubController.php @@ -21,7 +21,8 @@ final class IntegrationsHubController private readonly AllegroIntegrationRepository $allegro, private readonly ApaczkaIntegrationRepository $apaczka, private readonly InpostIntegrationRepository $inpost, - private readonly ShopproIntegrationsRepository $shoppro + private readonly ShopproIntegrationsRepository $shoppro, + private readonly FakturowniaIntegrationRepository $fakturownia ) { } @@ -33,6 +34,7 @@ final class IntegrationsHubController $this->buildApaczkaRow(), $this->buildInpostRow(), $this->buildShopproRow(), + $this->buildFakturowniaRow(), ]; $html = $this->template->render('settings/integrations', [ @@ -167,4 +169,49 @@ final class IntegrationsHubController ]; } + /** + * @return array + */ + private function buildFakturowniaRow(): array + { + $rows = $this->fakturownia->findAll(); + $instancesCount = count($rows); + $activeCount = 0; + $configuredCount = 0; + $lastTestAt = ''; + + foreach ($rows as $row) { + if (!empty($row['is_active'])) { + $activeCount++; + } + if (!empty($row['has_api_token'])) { + $configuredCount++; + } + + $testedAt = trim((string) ($row['last_test_at'] ?? '')); + if ($testedAt !== '' && ($lastTestAt === '' || strcmp($testedAt, $lastTestAt) > 0)) { + $lastTestAt = $testedAt; + } + } + + $instanceLabel = $instancesCount > 0 + ? 'Fakturownia (' . $instancesCount . ')' + : 'Fakturownia'; + + return [ + 'provider' => 'Fakturownia', + 'instance' => $instanceLabel, + 'authorization_status' => $configuredCount > 0 + ? $this->translator->get('settings.integrations_hub.status.configured') + : $this->translator->get('settings.integrations_hub.status.not_configured'), + 'secret_status' => $configuredCount > 0 + ? $this->translator->get('settings.integrations_hub.status.saved') + : $this->translator->get('settings.integrations_hub.status.missing'), + 'is_active' => $activeCount > 0, + 'last_test_at' => $lastTestAt, + 'configure_url' => '/settings/integrations/fakturownia', + 'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'), + ]; + } + } diff --git a/src/Modules/Settings/IntegrationsRepository.php b/src/Modules/Settings/IntegrationsRepository.php index a9f52b6..ae52b1f 100644 --- a/src/Modules/Settings/IntegrationsRepository.php +++ b/src/Modules/Settings/IntegrationsRepository.php @@ -124,6 +124,29 @@ final class IntegrationsRepository ]); } + public function updateTestResult(int $integrationId, string $status, ?int $httpCode, string $message): void + { + if ($integrationId <= 0) { + return; + } + + $statement = $this->pdo->prepare( + 'UPDATE integrations + SET last_test_status = :status, + last_test_http_code = :http_code, + last_test_message = :message, + last_test_at = NOW(), + updated_at = NOW() + WHERE id = :id' + ); + $statement->execute([ + 'id' => $integrationId, + 'status' => substr($status, 0, 16), + 'http_code' => $httpCode, + 'message' => substr($message, 0, 255), + ]); + } + public function getApiKeyEncrypted(int $integrationId): ?string { if ($integrationId <= 0) {