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) <noreply@anthropic.com>
This commit is contained in:
@@ -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*
|
||||
|
||||
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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)
|
||||
|
||||
34
.paul/changelog/2026-05-10.md
Normal file
34
.paul/changelog/2026-05-10.md
Normal file
@@ -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`
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
335
.paul/phases/113-fakturownia-integration/113-01-PLAN.md
Normal file
335
.paul/phases/113-fakturownia-integration/113-01-PLAN.md
Normal file
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
## 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)
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
## 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
|
||||
|
||||
<clarifications>
|
||||
- **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).
|
||||
</clarifications>
|
||||
</context>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## 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 "********")
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Migracje DB — invoices, invoice_configs, invoice_number_counters, fakturownia_integration_settings, orders.invoice_requested</name>
|
||||
<files>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</files>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
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.
|
||||
</verify>
|
||||
<done>AC-1 satisfied: schema + db_schema.md spójne.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: FakturowniaIntegrationRepository + FakturowniaApiClient (read/save/test)</name>
|
||||
<files>src/Modules/Settings/FakturowniaIntegrationRepository.php, src/Modules/Settings/FakturowniaApiClient.php, bootstrap/app.php, .paul/codebase/architecture.md</files>
|
||||
<action>
|
||||
`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).
|
||||
</action>
|
||||
<verify>
|
||||
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.
|
||||
</verify>
|
||||
<done>AC-2 satisfied: szyfrowanie + test API działa, integrations.last_test_* zapisuje przez IntegrationsRepository.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: FakturowniaIntegrationController + view + routes + karta w hubie</name>
|
||||
<files>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</files>
|
||||
<action>
|
||||
`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`.
|
||||
</action>
|
||||
<verify>
|
||||
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).
|
||||
</verify>
|
||||
<done>AC-3 satisfied: pełen flow integracji Fakturowni działa manualnie.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## 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.
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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).
|
||||
</output>
|
||||
207
.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md
Normal file
207
.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md
Normal file
@@ -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*
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
@@ -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)';
|
||||
120
resources/views/settings/fakturownia-edit.php
Normal file
120
resources/views/settings/fakturownia-edit.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/** @var array<string, mixed>|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 ?? ''));
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title"><?= $isNew ? 'Nowa integracja Fakturownia' : 'Edycja integracji Fakturownia' ?></h2>
|
||||
<p class="muted mt-12">Wystawianie faktur w aplikacji Fakturownia (app.fakturownia.pl).</p>
|
||||
|
||||
<?php if ($flashError !== ''): ?>
|
||||
<div class="alert alert--danger mt-12" role="alert"><?= $e($flashError) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashSave !== ''): ?>
|
||||
<div class="alert alert--success mt-12" role="status"><?= $e($flashSave) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashTest !== ''): ?>
|
||||
<div class="alert alert--info mt-12" role="status"><?= $e($flashTest) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16">
|
||||
<form class="statuses-form" action="/settings/integrations/fakturownia/save" method="post" novalidate>
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<?php if (!$isNew): ?>
|
||||
<input type="hidden" name="id" value="<?= $integrationId ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Nazwa integracji *</span>
|
||||
<input class="form-control" type="text" name="name" maxlength="128" value="<?= $e($name) ?>" required>
|
||||
<span class="muted">Dowolna etykieta pomocnicza (np. "Fakturownia - moja firma").</span>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Prefix konta (subdomena) *</span>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input class="form-control" type="text" name="account_prefix" maxlength="63" value="<?= $e($prefix) ?>" pattern="[a-z0-9][a-z0-9-]{1,62}" required style="flex:0 0 220px;">
|
||||
<span class="muted">.fakturownia.pl</span>
|
||||
</div>
|
||||
<span class="muted">Maly litery, cyfry, mysliniki. Tak jak w adresie panelu Fakturowni.</span>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Token API <?= $isNew ? '*' : '' ?></span>
|
||||
<input class="form-control" type="password" name="api_token" autocomplete="new-password" placeholder="<?= $hasToken ? '********' : '' ?>" <?= $isNew ? 'required' : '' ?>>
|
||||
<span class="muted"><?= $hasToken ? 'Token jest zapisany. Wpisz nowy aby nadpisac.' : 'Token API z Fakturowni (Ustawienia > Konta uzytkownikow > API).' ?></span>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">ID departamentu (opcjonalnie)</span>
|
||||
<input class="form-control" type="text" name="department_id" maxlength="64" value="<?= $e($departmentId) ?>">
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Domyslny typ dokumentu</span>
|
||||
<select class="form-control" name="default_kind">
|
||||
<option value="vat" <?= $defaultKind === 'vat' ? 'selected' : '' ?>>Faktura VAT</option>
|
||||
<option value="proforma" <?= $defaultKind === 'proforma' ? 'selected' : '' ?>>Proforma</option>
|
||||
<option value="invoice_other" <?= $defaultKind === 'invoice_other' ? 'selected' : '' ?>>Inna</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Domyslny termin platnosci (dni)</span>
|
||||
<input class="form-control" type="number" name="default_payment_to_days" min="0" max="120" value="<?= $defaultPaymentDays ?>" style="max-width:120px;">
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Status</span>
|
||||
<label style="display:inline-flex;align-items:center;gap:8px;">
|
||||
<input type="checkbox" name="is_active" value="1" <?= $isActive ? 'checked' : '' ?>>
|
||||
Integracja aktywna
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<div class="form-actions" style="display:flex;gap:8px;">
|
||||
<button type="submit" class="btn btn--primary">Zapisz</button>
|
||||
<a class="btn btn--secondary" href="/settings/integrations/fakturownia">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!$isNew): ?>
|
||||
<hr class="mt-16">
|
||||
<h3 class="section-title">Test polaczenia</h3>
|
||||
<p class="muted">Wykonuje GET <code><?= $e('https://' . ($prefix !== '' ? $prefix : '{prefix}') . '.fakturownia.pl/account.json') ?></code> z zapisanym tokenem.</p>
|
||||
<form action="/settings/integrations/fakturownia/test" method="post" style="margin-top:12px;">
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= $integrationId ?>">
|
||||
<button type="submit" class="btn btn--secondary">Testuj polaczenie</button>
|
||||
</form>
|
||||
|
||||
<?php if ($lastTestAt !== ''): ?>
|
||||
<div class="muted mt-12">
|
||||
Ostatni test: <strong><?= $e($lastTestAt) ?></strong>
|
||||
<?php if ($lastTestStatus !== ''): ?>
|
||||
— <strong><?= $e($lastTestStatus) ?></strong>
|
||||
<?php endif; ?>
|
||||
<?php if ($lastTestMessage !== ''): ?>
|
||||
<div><?= $e($lastTestMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
103
resources/views/settings/fakturownia.php
Normal file
103
resources/views/settings/fakturownia.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/** @var array<int, array<string, mixed>> $rows */
|
||||
$rows = is_array($rows ?? null) ? $rows : [];
|
||||
$flashSave = trim((string) ($flashSave ?? ''));
|
||||
$flashTest = trim((string) ($flashTest ?? ''));
|
||||
$flashError = trim((string) ($flashError ?? ''));
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">Integracje Fakturownia</h2>
|
||||
<p class="muted mt-12">Konfiguracja kont Fakturowni do wystawiania faktur dla zamowien.</p>
|
||||
|
||||
<?php if ($flashError !== ''): ?>
|
||||
<div class="alert alert--danger mt-12" role="alert"><?= $e($flashError) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashSave !== ''): ?>
|
||||
<div class="alert alert--success mt-12" role="status"><?= $e($flashSave) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashTest !== ''): ?>
|
||||
<div class="alert alert--info mt-12" role="status"><?= $e($flashTest) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16">
|
||||
<h3 class="section-title">Lista integracji</h3>
|
||||
|
||||
<div class="form-actions mt-12">
|
||||
<a class="btn btn--primary" href="/settings/integrations/fakturownia/new">Dodaj integracje</a>
|
||||
</div>
|
||||
|
||||
<?php if ($rows === []): ?>
|
||||
<p class="muted mt-12">Brak skonfigurowanych integracji Fakturowni. Dodaj pierwsza ponizej.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-wrap mt-12">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa</th>
|
||||
<th>Subdomena</th>
|
||||
<th>Token</th>
|
||||
<th>Status</th>
|
||||
<th>Ostatni test</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row):
|
||||
$integrationId = (int) ($row['integration_id'] ?? 0);
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
$prefix = (string) ($row['account_prefix'] ?? '');
|
||||
$hasToken = (bool) ($row['has_api_token'] ?? false);
|
||||
$isActive = (bool) ($row['is_active'] ?? false);
|
||||
$lastTestAt = (string) ($row['last_test_at'] ?? '');
|
||||
$lastTestStatus = (string) ($row['last_test_status'] ?? '');
|
||||
?>
|
||||
<tr>
|
||||
<td><?= $e($name) ?></td>
|
||||
<td>
|
||||
<?php if ($prefix !== ''): ?>
|
||||
<?= $e($prefix . '.fakturownia.pl') ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">brak</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($hasToken): ?>
|
||||
<span class="badge badge--success">Zapisany</span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge--muted">Brak</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($isActive): ?>
|
||||
<span class="badge badge--success">Aktywna</span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge--muted">Nieaktywna</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($lastTestAt !== ''): ?>
|
||||
<?= $e($lastTestAt) ?>
|
||||
<?php if ($lastTestStatus !== ''): ?>
|
||||
<span class="badge badge--<?= $lastTestStatus === 'ok' ? 'success' : 'muted' ?>"><?= $e(strtoupper($lastTestStatus)) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">nigdy</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/settings/integrations/fakturownia/edit?id=<?= $integrationId ?>" class="btn btn--sm btn--secondary">Edytuj</a>
|
||||
<form action="/settings/integrations/fakturownia/delete" method="post" style="display:inline" class="js-confirm-delete">
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= $integrationId ?>">
|
||||
<button type="button" class="btn btn--sm btn--danger js-delete-btn">Usun</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
@@ -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]);
|
||||
|
||||
158
src/Modules/Settings/FakturowniaApiClient.php
Normal file
158
src/Modules/Settings/FakturowniaApiClient.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\SslCertificateResolver;
|
||||
use RuntimeException;
|
||||
|
||||
final class FakturowniaApiClient
|
||||
{
|
||||
public function __construct(private readonly int $timeoutSeconds = 15)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, http_code: int, message: string}
|
||||
*/
|
||||
public function testConnection(string $accountPrefix, string $apiToken): array
|
||||
{
|
||||
$prefix = strtolower(trim($accountPrefix));
|
||||
$token = trim($apiToken);
|
||||
|
||||
if ($prefix === '' || $token === '') {
|
||||
return [
|
||||
'ok' => 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<string, mixed> $settings
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
186
src/Modules/Settings/FakturowniaIntegrationController.php
Normal file
186
src/Modules/Settings/FakturowniaIntegrationController.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\RedirectPathResolver;
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use Throwable;
|
||||
|
||||
final class FakturowniaIntegrationController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly FakturowniaIntegrationRepository $repository,
|
||||
private readonly FakturowniaApiClient $apiClient,
|
||||
private readonly IntegrationsRepository $integrations
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$rows = $this->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);
|
||||
}
|
||||
}
|
||||
269
src/Modules/Settings/FakturowniaIntegrationRepository.php
Normal file
269
src/Modules/Settings/FakturowniaIntegrationRepository.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use App\Core\Support\StringHelper;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class FakturowniaIntegrationRepository
|
||||
{
|
||||
private const INTEGRATION_TYPE = 'fakturownia';
|
||||
private const INTEGRATION_BASE_URL = 'https://app.fakturownia.pl';
|
||||
|
||||
private readonly IntegrationsRepository $integrations;
|
||||
private readonly IntegrationSecretCipher $cipher;
|
||||
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly string $secret
|
||||
) {
|
||||
$this->integrations = new IntegrationsRepository($this->pdo);
|
||||
$this->cipher = new IntegrationSecretCipher($this->secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>|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<string, mixed> $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<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'] : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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'),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user