diff --git a/.paul/phases/118-fakturownia-single-instance/118-01-PLAN.md b/.paul/phases/118-fakturownia-single-instance/118-01-PLAN.md new file mode 100644 index 0000000..d1b6e73 --- /dev/null +++ b/.paul/phases/118-fakturownia-single-instance/118-01-PLAN.md @@ -0,0 +1,210 @@ +--- +phase: 118-fakturownia-single-instance +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - database/migrations/20260512_000109_fakturownia_single_instance.sql + - src/Modules/Settings/FakturowniaIntegrationRepository.php + - src/Modules/Settings/FakturowniaIntegrationController.php + - src/Modules/Settings/IntegrationsHubController.php + - src/Modules/Settings/InvoiceConfigRepository.php + - src/Modules/Settings/InvoiceConfigController.php + - src/Modules/Accounting/InvoiceService.php + - resources/views/settings/fakturownia.php + - resources/views/settings/fakturownia-edit.php + - resources/views/settings/accounting-invoice-edit.php + - routes/web.php + - DOCS/DB_SCHEMA.md + - DOCS/ARCHITECTURE.md + - DOCS/TECH_CHANGELOG.md +autonomous: true +delegation: off +--- + + +## Goal +Convert Fakturownia from a multi-account integration to one global integration instance, like HostedSMS/SMSPLANET. + +## Purpose +The operator should configure Fakturownia once. Invoice configs may still delegate invoice issuing to Fakturownia, but they must all point to the single global Fakturownia integration row. + +## Output +One migration, one single-instance repository/controller/UI flow, updated invoice config handling, and updated technical documentation. + + + + +- **Migracja** - Co zrobic z istniejacymi wieloma kontami Fakturowni, jesli sa juz w bazie? + -> Odpowiedz: Wybrac aktywne konto. +- **Kontrakty** - Czy delegowane konfiguracje faktur maja nadal trzymac `integration_id`, ale zawsze wskazywac jedyna globalna Fakturownie? + -> Odpowiedz: Tak. +- **UI** - Jak ma wygladac UI Fakturowni? + -> Odpowiedz: Jedna strona z formularzem konfiguracji i testem polaczenia, bez dodawania wielu instancji. + + +## Project Context +@.paul/PROJECT.md +@.paul/ROADMAP.md +@.paul/STATE.md +@DOCS/DB_SCHEMA.md +@DOCS/ARCHITECTURE.md + +## Source Files +@src/Modules/Settings/FakturowniaIntegrationRepository.php +@src/Modules/Settings/FakturowniaIntegrationController.php +@src/Modules/Settings/HostedSmsIntegrationRepository.php +@src/Modules/Settings/IntegrationsHubController.php +@src/Modules/Settings/InvoiceConfigRepository.php +@src/Modules/Settings/InvoiceConfigController.php +@src/Modules/Accounting/InvoiceService.php +@resources/views/settings/fakturownia.php +@resources/views/settings/fakturownia-edit.php +@resources/views/settings/accounting-invoice-edit.php +@routes/web.php + +## Prior Work +@.paul/phases/113-fakturownia-integration/113-01-SUMMARY.md +@.paul/phases/114-accounting-configs-refactor/114-01-SUMMARY.md +@.paul/phases/115-invoice-from-order/115-01-SUMMARY.md +@.paul/phases/116-hostedsms-integration/116-01-SUMMARY.md + + + +## Required Skills + +No blocking skills before APPLY. + +Project SPECIAL-FLOWS requires `sonar-scanner` after APPLY and before UNIFY when available in PATH. + + + + +## AC-1: Jedna globalna konfiguracja Fakturowni +```gherkin +Given the operator opens /settings/integrations/fakturownia +When the page renders +Then there is one settings form with save and test actions, and no UI for adding, listing, or deleting multiple Fakturownia accounts +``` + +## AC-2: Migracja wybiera aktywna instancje +```gherkin +Given multiple integrations with type "fakturownia" exist before migration +When the migration runs +Then the active integration is preserved as the single global row, invoice_configs.integration_id values are reassigned to it, settings are preserved for that row, and extra unused Fakturownia rows are removed or detached safely +``` + +## AC-3: Delegacja faktur pozostaje kompatybilna +```gherkin +Given an invoice config has is_delegated = 1 +When it is saved or used to issue an invoice +Then it points to the single global Fakturownia integration_id and InvoiceService can still call Fakturownia without schema changes +``` + +## AC-4: Hub integracji pokazuje pojedynczy status +```gherkin +Given the integrations hub is opened +When Fakturownia row is displayed +Then it shows one provider row with configured/active/token/test status for the global instance, not a count of instances +``` + +## AC-5: Dokumentacja jest aktualna +```gherkin +Given the implementation is complete +When DOCS are reviewed +Then DB_SCHEMA, ARCHITECTURE, and TECH_CHANGELOG describe Fakturownia as a fixed single-instance integration and note the migration behavior +``` + + + + + + + Task 1: Add single-instance migration + database/migrations/20260512_000109_fakturownia_single_instance.sql, DOCS/DB_SCHEMA.md + + Create an idempotent migration that enforces the single-instance Fakturownia model without dropping existing invoice history. + - Pick the preserved row by priority: active Fakturownia integration first, then the one referenced by invoice_configs, then the lowest id. + - Ensure a base integrations row exists with type=fakturownia and name=Fakturownia if none exists. + - Ensure one fakturownia_integration_settings row exists for the preserved integration. + - Reassign invoice_configs.integration_id for delegated configs to the preserved integration. + - Remove extra fakturownia_integration_settings/integrations rows only after reassignment, using prepared migration SQL and FK-safe order. + - Add/adjust constraints so settings are fixed to id=1 when feasible without breaking MySQL compatibility. + Avoid: deleting invoices, changing invoices.config_id, or removing the invoice_configs.integration_id column. + + C:\xampp\php\php.exe bin\migrate.php on a database with zero, one, and multiple Fakturownia rows; inspect integrations/fakturownia_integration_settings/invoice_configs after migration. + AC-2 and AC-3 satisfied. + + + + Task 2: Refactor Fakturownia repository/controller/UI to single settings page + src/Modules/Settings/FakturowniaIntegrationRepository.php, src/Modules/Settings/FakturowniaIntegrationController.php, resources/views/settings/fakturownia.php, resources/views/settings/fakturownia-edit.php, routes/web.php, src/Modules/Settings/IntegrationsHubController.php + + Replace the multi-account behavior with HostedSMS-style single settings behavior. + - Repository exposes getSettings(), saveSettings(), getCredentials()/getDecryptedToken() and getIntegrationId() for the fixed integration. + - Save updates the single integration row and single settings row; empty token preserves the old encrypted token. + - Controller index renders one page with the form and test panel; save/test redirect back to /settings/integrations/fakturownia. + - Remove or neutralize /new, /edit, /delete routes and links for Fakturownia. + - Hub row uses the single settings object and no longer displays instance counts. + Avoid: native alert()/confirm(), inline CSS in new view code, and SQL string concatenation. + + C:\xampp\php\php.exe -l on touched PHP files; manually open /settings/integrations and /settings/integrations/fakturownia, save settings, and run connection test. + AC-1 and AC-4 satisfied. + + + + Task 3: Keep invoice config delegation compatible with the global integration + src/Modules/Settings/InvoiceConfigRepository.php, src/Modules/Settings/InvoiceConfigController.php, src/Modules/Accounting/InvoiceService.php, resources/views/settings/accounting-invoice-edit.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md + + Update invoice config handling so delegated configs always use the single Fakturownia integration. + - When saving delegated invoice configs, ignore user-supplied multi-account choice and resolve the global Fakturownia integration_id from the repository. + - Simplify accounting invoice edit UI so delegation does not show a multi-account selector; show a compact hint/status that Fakturownia must be configured globally. + - Ensure InvoiceService still reads config integration_id and retrieves credentials through the updated repository. + - Update docs and changelog with new single-instance contract and migration behavior. + Avoid: schema removal of invoice_configs.integration_id and behavior changes for local non-delegated invoice configs. + + C:\xampp\php\php.exe -l on touched PHP files; create/edit local and delegated invoice configs; issue a delegated invoice against a configured Fakturownia account in manual UAT. + AC-3 and AC-5 satisfied. + + + + + + +## DO NOT CHANGE +- Do not remove `invoice_configs.integration_id`. +- Do not change existing `invoices` rows or invoice PDF rendering. +- Do not add `invoice.created` automation event. +- Do not implement Fakturownia double-POST idempotency in this plan. +- Do not change HostedSMS/SMSPLANET behavior. + +## SCOPE LIMITS +- This plan only changes Fakturownia account configuration from multi-instance to single-instance. +- Existing delegated invoice issuing remains functionally the same after it resolves the global account. +- Live Fakturownia API testing depends on operator credentials and network access. + + + + +Before declaring plan complete: +- [ ] `C:\xampp\php\php.exe bin\migrate.php` +- [ ] `C:\xampp\php\php.exe -l` for all touched PHP files +- [ ] Manual UI smoke: integrations hub, Fakturownia settings save, Fakturownia test +- [ ] Manual UI smoke: invoice config create/edit for local and delegated modes +- [ ] Delegated invoice issue still calls Fakturownia with the global account +- [ ] DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md updated +- [ ] `sonar-scanner` run after APPLY if available in PATH + + + +- Fakturownia has exactly one configurable instance from the UI. +- Migration preserves one active Fakturownia row and rewires delegated invoice configs to it. +- No route or view offers adding/deleting multiple Fakturownia accounts. +- Delegated invoice configs and delegated invoice issuing remain compatible. +- All verification checks pass or environment-dependent gaps are documented in SUMMARY.md. + + + +After completion, create `.paul/phases/118-fakturownia-single-instance/118-01-SUMMARY.md`. + diff --git a/.paul/phases/118-fakturownia-single-instance/118-01-SUMMARY.md b/.paul/phases/118-fakturownia-single-instance/118-01-SUMMARY.md new file mode 100644 index 0000000..7edbfbb --- /dev/null +++ b/.paul/phases/118-fakturownia-single-instance/118-01-SUMMARY.md @@ -0,0 +1,52 @@ +# Phase 118 Plan 01 Summary - Fakturownia Single Instance + +## Status + +APPLY complete on 2026-05-12 13:47. Ready for UNIFY. + +## Implemented + +- Added `database/migrations/20260512_000109_fakturownia_single_instance.sql`. +- Converted `FakturowniaIntegrationRepository` to one global settings row with `getSettings()`, `saveSettings()`, `getIntegrationId()`, `getCredentials()`, and compatibility wrappers. +- Simplified `FakturowniaIntegrationController` to one settings page plus save/test actions. +- Replaced Fakturownia multi-account list UI with a single configuration form and test panel. +- Updated integrations hub to show Fakturownia as one instance. +- Updated delegated invoice config save flow to always use the global Fakturownia `integration_id`. +- Removed the invoice config account selector and replaced it with global Fakturownia status. +- Updated `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, and `DOCS/TECH_CHANGELOG.md`. +- Updated `.paul/codebase/db_schema.md`, `.paul/codebase/architecture.md`, and `.paul/codebase/tech_changelog.md`. + +## Verification + +- PASS: `C:\xampp\php\php.exe -l` for changed PHP classes and `routes/web.php`. +- PASS: `C:\xampp\php\php.exe -l` for changed PHP views. +- PASS: static search found no active UI links for adding/deleting Fakturownia accounts; legacy `/new` and `/edit` routes redirect to the global settings page. +- BLOCKED: `C:\xampp\php\php.exe bin\migrate.php` could not connect to local MySQL (`SQLSTATE[HY000] [2002]` target actively refused connection). +- SKIPPED: PHPUnit is not available in `vendor/bin`. +- SKIPPED: `sonar-scanner` is not available in PATH. + +## Environment Gaps + +- Run `C:\xampp\php\php.exe bin\migrate.php` after local MySQL/XAMPP is running. +- Manually verify `/settings/integrations`, `/settings/integrations/fakturownia`, save/test Fakturownia settings, and local/delegated invoice config edit. +- Live Fakturownia API test still requires valid credentials and network access. + +## Files Changed + +- `database/migrations/20260512_000109_fakturownia_single_instance.sql` +- `src/Modules/Settings/FakturowniaIntegrationRepository.php` +- `src/Modules/Settings/FakturowniaIntegrationController.php` +- `src/Modules/Settings/IntegrationsHubController.php` +- `src/Modules/Settings/InvoiceConfigRepository.php` +- `src/Modules/Settings/InvoiceConfigController.php` +- `src/Modules/Accounting/InvoiceService.php` +- `resources/views/settings/fakturownia.php` +- `resources/views/settings/fakturownia-edit.php` +- `resources/views/settings/accounting-invoice-edit.php` +- `routes/web.php` +- `DOCS/DB_SCHEMA.md` +- `DOCS/ARCHITECTURE.md` +- `DOCS/TECH_CHANGELOG.md` +- `.paul/codebase/db_schema.md` +- `.paul/codebase/architecture.md` +- `.paul/codebase/tech_changelog.md` diff --git a/database/migrations/20260512_000109_fakturownia_single_instance.sql b/database/migrations/20260512_000109_fakturownia_single_instance.sql new file mode 100644 index 0000000..c0f882e --- /dev/null +++ b/database/migrations/20260512_000109_fakturownia_single_instance.sql @@ -0,0 +1,74 @@ +INSERT INTO `integrations` (`type`, `name`, `base_url`, `timeout_seconds`, `is_active`, `created_at`, `updated_at`) +SELECT 'fakturownia', 'Fakturownia', 'https://app.fakturownia.pl', 15, 1, NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM `integrations` WHERE `type` = 'fakturownia' +); + +SET @fakturownia_integration_id := ( + SELECT `id` + FROM ( + SELECT + i.`id`, + i.`is_active`, + COALESCE(usage_counts.`used_count`, 0) AS `used_count` + FROM `integrations` i + LEFT JOIN ( + SELECT `integration_id`, COUNT(*) AS `used_count` + FROM `invoice_configs` + WHERE `integration_id` IS NOT NULL + GROUP BY `integration_id` + ) usage_counts ON usage_counts.`integration_id` = i.`id` + WHERE i.`type` = 'fakturownia' + ORDER BY i.`is_active` DESC, COALESCE(usage_counts.`used_count`, 0) DESC, i.`id` ASC + LIMIT 1 + ) selected_fakturownia +); + +UPDATE `integrations` +SET `name` = CONCAT(`name`, ' #', `id`), + `updated_at` = NOW() +WHERE `type` = 'fakturownia' + AND `id` <> @fakturownia_integration_id + AND `name` = 'Fakturownia'; + +UPDATE `integrations` +SET `name` = 'Fakturownia', + `base_url` = 'https://app.fakturownia.pl', + `timeout_seconds` = 15, + `updated_at` = NOW() +WHERE `id` = @fakturownia_integration_id + AND `type` = 'fakturownia'; + +UPDATE `invoice_configs` +SET `integration_id` = @fakturownia_integration_id, + `updated_at` = NOW() +WHERE `is_delegated` = 1; + +UPDATE `invoice_configs` +SET `integration_id` = NULL, + `updated_at` = NOW() +WHERE `is_delegated` = 0; + +DELETE FROM `fakturownia_integration_settings` +WHERE `integration_id` <> @fakturownia_integration_id; + +INSERT INTO `fakturownia_integration_settings` + (`id`, `integration_id`, `account_prefix`, `default_kind`, `default_payment_to_days`, `created_at`, `updated_at`) +SELECT 1, @fakturownia_integration_id, '', 'vat', 7, NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 + FROM `fakturownia_integration_settings` + WHERE `integration_id` = @fakturownia_integration_id +); + +UPDATE `fakturownia_integration_settings` +SET `id` = 1, + `integration_id` = @fakturownia_integration_id, + `updated_at` = NOW() +WHERE `integration_id` = @fakturownia_integration_id; + +DELETE FROM `integrations` +WHERE `type` = 'fakturownia' + AND `id` <> @fakturownia_integration_id; + +ALTER TABLE `fakturownia_integration_settings` AUTO_INCREMENT = 2; diff --git a/resources/views/settings/fakturownia-edit.php b/resources/views/settings/fakturownia-edit.php index 541a45b..ff22670 100644 --- a/resources/views/settings/fakturownia-edit.php +++ b/resources/views/settings/fakturownia-edit.php @@ -1,120 +1,9 @@ |null $row */ -$row = is_array($row ?? null) ? $row : null; -$isNew = $row === null; -$integrationId = (int) ($row['integration_id'] ?? 0); -$name = (string) ($row['name'] ?? ''); -$prefix = (string) ($row['account_prefix'] ?? ''); -$departmentId = (string) ($row['department_id'] ?? ''); -$defaultKind = (string) ($row['default_kind'] ?? 'vat'); -$defaultPaymentDays = (int) ($row['default_payment_to_days'] ?? 7); -$isActive = $isNew ? true : (bool) ($row['is_active'] ?? false); -$hasToken = (bool) ($row['has_api_token'] ?? false); -$lastTestAt = (string) ($row['last_test_at'] ?? ''); -$lastTestStatus = (string) ($row['last_test_status'] ?? ''); -$lastTestMessage = (string) ($row['last_test_message'] ?? ''); - -$flashSave = trim((string) ($flashSave ?? '')); -$flashTest = trim((string) ($flashTest ?? '')); -$flashError = trim((string) ($flashError ?? '')); ?> -
-

-

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

- - - - - -
- - -
- -
- -
-
- - - - - - - - - - - - - - - - - - - -
- - Anuluj -
-
- - -
-

Test polaczenia

-

Wykonuje GET z zapisanym tokenem.

-
- - - -
- - -
- Ostatni test: - - — - - -
- -
- - +

Integracja Fakturownia

+

Fakturownia ma jedna globalna konfiguracje. Wroc do strony konfiguracji.

+
+ Otworz konfiguracje +
diff --git a/src/Modules/Accounting/InvoiceService.php b/src/Modules/Accounting/InvoiceService.php index 5be1451..f99b8b4 100644 --- a/src/Modules/Accounting/InvoiceService.php +++ b/src/Modules/Accounting/InvoiceService.php @@ -192,12 +192,12 @@ final class InvoiceService $account = $this->fakturownia->findByIntegrationId($integrationId); if ($account === null) { - throw new InvoiceIssueException('Konto Fakturownia nie istnieje (id=' . $integrationId . ').'); + throw new InvoiceIssueException('Globalna konfiguracja Fakturowni nie istnieje (id=' . $integrationId . ').'); } $prefix = trim((string) ($account['account_prefix'] ?? '')); if ($prefix === '') { - throw new InvoiceIssueException('Konto Fakturownia nie ma ustawionego prefiksu (subdomeny).'); + throw new InvoiceIssueException('Globalna konfiguracja Fakturowni nie ma ustawionego prefiksu (subdomeny).'); } $apiToken = $this->fakturownia->getDecryptedToken($integrationId); diff --git a/src/Modules/Settings/FakturowniaIntegrationController.php b/src/Modules/Settings/FakturowniaIntegrationController.php index c132bf3..ec3988a 100644 --- a/src/Modules/Settings/FakturowniaIntegrationController.php +++ b/src/Modules/Settings/FakturowniaIntegrationController.php @@ -3,7 +3,6 @@ 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; @@ -27,15 +26,13 @@ final class FakturowniaIntegrationController 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, + 'settings' => $this->repository->getSettings(), 'flashSave' => (string) Flash::get('fakturownia.save', ''), 'flashTest' => (string) Flash::get('fakturownia.test', ''), 'flashError' => (string) Flash::get('fakturownia.error', ''), @@ -46,34 +43,11 @@ final class FakturowniaIntegrationController 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); + return Response::redirect('/settings/integrations/fakturownia'); } public function save(Request $request): Response { - $integrationId = (int) $request->input('id', 0); $redirectTo = '/settings/integrations/fakturownia'; if (!Csrf::validate((string) $request->input('_token', ''))) { @@ -82,29 +56,18 @@ final class FakturowniaIntegrationController } 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', ''), - ] - ); + $this->repository->saveSettings([ + '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.'); + Flash::set('fakturownia.save', 'Zapisano konfiguracje 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' - )); + Flash::set('fakturownia.error', 'Nie udalo sie zapisac konfiguracji: ' . $exception->getMessage()); } return Response::redirect($redirectTo); @@ -112,31 +75,19 @@ final class FakturowniaIntegrationController public function test(Request $request): Response { - $integrationId = (int) $request->input('id', 0); - $redirectTo = $integrationId > 0 - ? '/settings/integrations/fakturownia/edit?id=' . $integrationId - : '/settings/integrations/fakturownia'; + $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', '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'] ?? ''); + $settings = $this->repository->getSettings(); + $integrationId = (int) ($settings['integration_id'] ?? 0); + $prefix = (string) ($settings['account_prefix'] ?? ''); $token = $this->repository->getDecryptedToken($integrationId); - if ($prefix === '' || $token === null || $token === '') { + if ($integrationId <= 0 || $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); @@ -151,36 +102,18 @@ final class FakturowniaIntegrationController (string) $result['message'] ); - $msg = $result['ok'] + $message = $result['ok'] ? 'OK (HTTP ' . (int) $result['http_code'] . ')' : 'BLAD: ' . $result['message'] . ' (HTTP ' . (int) $result['http_code'] . ')'; - Flash::set('fakturownia.test', $msg); + Flash::set('fakturownia.test', $message); return Response::redirect($redirectTo); } public function delete(Request $request): Response { - $integrationId = (int) $request->input('id', 0); - $redirectTo = '/settings/integrations/fakturownia'; + Flash::set('fakturownia.error', 'Fakturownia ma jedna globalna konfiguracje i nie moze byc usunieta.'); - 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); + return Response::redirect('/settings/integrations/fakturownia'); } } diff --git a/src/Modules/Settings/FakturowniaIntegrationRepository.php b/src/Modules/Settings/FakturowniaIntegrationRepository.php index afcf6ce..2107e5c 100644 --- a/src/Modules/Settings/FakturowniaIntegrationRepository.php +++ b/src/Modules/Settings/FakturowniaIntegrationRepository.php @@ -11,6 +11,7 @@ use Throwable; final class FakturowniaIntegrationRepository { private const INTEGRATION_TYPE = 'fakturownia'; + private const INTEGRATION_NAME = 'Fakturownia'; private const INTEGRATION_BASE_URL = 'https://app.fakturownia.pl'; private readonly IntegrationsRepository $integrations; @@ -25,28 +26,44 @@ final class FakturowniaIntegrationRepository } /** + * @return array + */ + public function getSettings(): array + { + $this->ensureRow(); + $integrationId = $this->getIntegrationId(); + $row = $this->fetchSettingsRow(); + $integration = $this->integrations->findById($integrationId); + $encryptedToken = $this->resolveApiTokenEncrypted($row, $integration); + + return [ + 'integration_id' => $integrationId, + 'settings_id' => isset($row['id']) ? (int) $row['id'] : null, + 'name' => (string) ($integration['name'] ?? self::INTEGRATION_NAME), + 'is_active' => (int) ($integration['is_active'] ?? 1) === 1, + 'account_prefix' => trim((string) ($row['account_prefix'] ?? '')), + 'api_token_encrypted' => $encryptedToken, + 'has_api_token' => $encryptedToken !== null && $encryptedToken !== '', + 'department_id' => trim((string) ($row['department_id'] ?? '')), + 'default_kind' => trim((string) ($row['default_kind'] ?? 'vat')), + 'default_payment_to_days' => (int) ($row['default_payment_to_days'] ?? 7), + 'last_test_status' => trim((string) ($integration['last_test_status'] ?? '')), + 'last_test_http_code' => isset($integration['last_test_http_code']) + ? (int) $integration['last_test_http_code'] + : null, + 'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')), + 'last_test_at' => trim((string) ($integration['last_test_at'] ?? '')), + ]; + } + + /** + * Backward-compatible list API for callers that still expect accounts collection. + * * @return array> */ public function findAll(): array { - try { - $statement = $this->pdo->prepare( - 'SELECT i.id AS integration_id, i.name, i.is_active, - i.last_test_status, i.last_test_http_code, i.last_test_message, i.last_test_at, - s.id AS settings_id, s.account_prefix, s.api_token_encrypted, - s.department_id, s.default_kind, s.default_payment_to_days - FROM integrations i - LEFT JOIN fakturownia_integration_settings s ON s.integration_id = i.id - WHERE i.type = :type - ORDER BY i.id ASC' - ); - $statement->execute(['type' => self::INTEGRATION_TYPE]); - $rows = $statement->fetchAll(PDO::FETCH_ASSOC); - } catch (Throwable) { - return []; - } - - return is_array($rows) ? array_map(fn (array $row) => $this->mapRow($row), $rows) : []; + return [$this->getSettings()]; } /** @@ -54,41 +71,24 @@ final class FakturowniaIntegrationRepository */ public function findByIntegrationId(int $integrationId): ?array { - if ($integrationId <= 0) { + $globalId = $this->getIntegrationId(); + if ($integrationId <= 0 || $integrationId !== $globalId) { 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; + return $this->getSettings(); } /** * @param array $payload */ - public function save(?int $integrationId, array $payload): int + public function saveSettings(array $payload): void { - $name = trim((string) ($payload['name'] ?? '')); - if ($name === '') { - throw new IntegrationConfigException('Nazwa integracji Fakturowni jest wymagana.'); + $this->ensureRow(); + $integrationId = $this->getIntegrationId(); + $row = $this->fetchSettingsRow(); + if ($row === null) { + throw new IntegrationConfigException('Brak rekordu konfiguracji Fakturowni.'); } $prefix = strtolower(trim((string) ($payload['account_prefix'] ?? ''))); @@ -96,130 +96,180 @@ final class FakturowniaIntegrationRepository 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); + if (mb_strlen($defaultKind) > 32) { + throw new IntegrationConfigException('Typ dokumentu jest za dlugi (max 32 znaki).'); } - $current = $this->findByIntegrationId($integrationId); - $currentEncrypted = $current['api_token_encrypted'] ?? null; + $defaultPaymentDays = (int) ($payload['default_payment_to_days'] ?? 7); + if ($defaultPaymentDays < 0) { + $defaultPaymentDays = 0; + } + if ($defaultPaymentDays > 120) { + $defaultPaymentDays = 120; + } + $departmentId = StringHelper::nullableString(trim((string) ($payload['department_id'] ?? ''))); + $currentEncrypted = $this->resolveApiTokenEncrypted($row, $this->integrations->findById($integrationId)); $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).' - ); + if ($nextEncrypted === null || $nextEncrypted === '') { + throw new IntegrationConfigException('Podaj token API Fakturowni.'); } $statement = $this->pdo->prepare( - 'DELETE FROM integrations WHERE id = :id AND type = :type' + '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 id = 1' ); $statement->execute([ - 'id' => $integrationId, - 'type' => self::INTEGRATION_TYPE, + 'account_prefix' => $prefix, + 'api_token_encrypted' => $nextEncrypted, + 'department_id' => $departmentId, + 'default_kind' => $defaultKind, + 'default_payment_to_days' => $defaultPaymentDays, ]); + + $this->updateIntegrationActive($integrationId, !empty($payload['is_active'])); + $this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted); + } + + /** + * Compatibility wrapper for old callers. + * + * @param array $payload + */ + public function save(?int $integrationId, array $payload): int + { + $this->saveSettings($payload); + + return $this->getIntegrationId(); + } + + /** + * @return array{integration_id: int, account_prefix: string, api_token: string, department_id: string, default_kind: string, default_payment_to_days: int}|null + */ + public function getCredentials(): ?array + { + $settings = $this->getSettings(); + $integrationId = (int) ($settings['integration_id'] ?? 0); + $prefix = trim((string) ($settings['account_prefix'] ?? '')); + $encrypted = $settings['api_token_encrypted'] ?? null; + if ($integrationId <= 0 || $prefix === '' || !is_string($encrypted) || trim($encrypted) === '') { + return null; + } + + $token = trim($this->cipher->decrypt($encrypted)); + if ($token === '') { + return null; + } + + return [ + 'integration_id' => $integrationId, + 'account_prefix' => $prefix, + 'api_token' => $token, + 'department_id' => trim((string) ($settings['department_id'] ?? '')), + 'default_kind' => trim((string) ($settings['default_kind'] ?? 'vat')), + 'default_payment_to_days' => (int) ($settings['default_payment_to_days'] ?? 7), + ]; + } + + public function getIntegrationId(): int + { + $existing = $this->integrations->findFirstByType(self::INTEGRATION_TYPE); + if ($existing !== null) { + return (int) ($existing['id'] ?? 0); + } + + return $this->integrations->ensureIntegration( + self::INTEGRATION_TYPE, + self::INTEGRATION_NAME, + self::INTEGRATION_BASE_URL, + 15, + true + ); } public function getDecryptedToken(int $integrationId): ?string { - $row = $this->findByIntegrationId($integrationId); - if ($row === null) { + $globalId = $this->getIntegrationId(); + if ($integrationId !== $globalId) { return null; } - $encrypted = $row['api_token_encrypted'] ?? null; - if (!is_string($encrypted) || $encrypted === '') { - return null; - } + $credentials = $this->getCredentials(); - return $this->cipher->decrypt($encrypted); + return $credentials['api_token'] ?? null; } - private function isUsedByInvoiceConfig(int $integrationId): bool + public function delete(int $integrationId): void + { + throw new IntegrationConfigException('Fakturownia ma jedna globalna konfiguracje i nie moze byc usunieta z UI.'); + } + + private function ensureRow(): void + { + $integrationId = $this->getIntegrationId(); + $statement = $this->pdo->prepare( + 'INSERT INTO fakturownia_integration_settings + (id, integration_id, account_prefix, default_kind, default_payment_to_days, created_at, updated_at) + VALUES + (1, :integration_id, "", "vat", 7, NOW(), NOW()) + ON DUPLICATE KEY UPDATE integration_id = VALUES(integration_id), updated_at = VALUES(updated_at)' + ); + $statement->execute(['integration_id' => $integrationId]); + } + + /** + * @return array|null + */ + private function fetchSettingsRow(): ?array { try { - $statement = $this->pdo->prepare( - 'SELECT 1 FROM invoice_configs WHERE integration_id = :id LIMIT 1' - ); - $statement->execute(['id' => $integrationId]); - return $statement->fetchColumn() !== false; + $statement = $this->pdo->prepare('SELECT * FROM fakturownia_integration_settings WHERE id = 1 LIMIT 1'); + $statement->execute(); + $row = $statement->fetch(PDO::FETCH_ASSOC); } catch (Throwable) { - return false; + return null; } + + return is_array($row) ? $row : null; } - private function updateIntegrationRow(int $integrationId, string $name, bool $isActive): void + /** + * @param array|null $row + * @param array|null $integration + */ + private function resolveApiTokenEncrypted(?array $row, ?array $integration): ?string + { + $settingsValue = trim((string) ($row['api_token_encrypted'] ?? '')); + if ($settingsValue !== '') { + return $settingsValue; + } + + $baseValue = trim((string) ($integration['api_key_encrypted'] ?? '')); + return StringHelper::nullableString($baseValue); + } + + private function updateIntegrationActive(int $integrationId, bool $isActive): void { $statement = $this->pdo->prepare( 'UPDATE integrations SET name = :name, + base_url = :base_url, + timeout_seconds = :timeout_seconds, is_active = :is_active, updated_at = NOW() WHERE id = :id AND type = :type' @@ -227,43 +277,10 @@ final class FakturowniaIntegrationRepository $statement->execute([ 'id' => $integrationId, 'type' => self::INTEGRATION_TYPE, - 'name' => $name, + 'name' => self::INTEGRATION_NAME, + 'base_url' => self::INTEGRATION_BASE_URL, + 'timeout_seconds' => 15, 'is_active' => $isActive ? 1 : 0, ]); } - - /** - * @param array $row - * @return array - */ - private function mapRow(array $row): array - { - $integrationId = (int) ($row['integration_id'] ?? 0); - $baseEncrypted = $this->integrations->getApiKeyEncrypted($integrationId); - $settingsEncrypted = isset($row['api_token_encrypted']) ? trim((string) $row['api_token_encrypted']) : ''; - - $resolvedEncrypted = null; - if ($baseEncrypted !== null && $baseEncrypted !== '') { - $resolvedEncrypted = $baseEncrypted; - } elseif ($settingsEncrypted !== '') { - $resolvedEncrypted = $settingsEncrypted; - } - - return [ - 'integration_id' => $integrationId, - 'settings_id' => isset($row['settings_id']) ? (int) $row['settings_id'] : null, - 'name' => (string) ($row['name'] ?? ''), - 'is_active' => (bool) ($row['is_active'] ?? false), - 'account_prefix' => (string) ($row['account_prefix'] ?? ''), - 'api_token_encrypted' => $resolvedEncrypted, - 'has_api_token' => $resolvedEncrypted !== null && $resolvedEncrypted !== '', - 'department_id' => isset($row['department_id']) ? (string) $row['department_id'] : '', - 'default_kind' => (string) ($row['default_kind'] ?? 'vat'), - 'default_payment_to_days' => (int) ($row['default_payment_to_days'] ?? 7), - 'last_test_status' => isset($row['last_test_status']) ? (string) $row['last_test_status'] : '', - 'last_test_http_code' => isset($row['last_test_http_code']) ? (int) $row['last_test_http_code'] : null, - 'last_test_message' => isset($row['last_test_message']) ? (string) $row['last_test_message'] : '', - 'last_test_at' => isset($row['last_test_at']) ? (string) $row['last_test_at'] : '', - ]; - } } diff --git a/src/Modules/Settings/IntegrationsHubController.php b/src/Modules/Settings/IntegrationsHubController.php index cae52f4..b2b4afc 100644 --- a/src/Modules/Settings/IntegrationsHubController.php +++ b/src/Modules/Settings/IntegrationsHubController.php @@ -178,41 +178,21 @@ final class IntegrationsHubController */ 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'; + $settings = $this->fakturownia->getSettings(); + $isConfigured = trim((string) ($settings['account_prefix'] ?? '')) !== '' + && !empty($settings['has_api_token']); return [ 'provider' => 'Fakturownia', - 'instance' => $instanceLabel, - 'authorization_status' => $configuredCount > 0 + 'instance' => 'Fakturownia', + 'authorization_status' => $isConfigured ? $this->translator->get('settings.integrations_hub.status.configured') : $this->translator->get('settings.integrations_hub.status.not_configured'), - 'secret_status' => $configuredCount > 0 + 'secret_status' => !empty($settings['has_api_token']) ? $this->translator->get('settings.integrations_hub.status.saved') : $this->translator->get('settings.integrations_hub.status.missing'), - 'is_active' => $activeCount > 0, - 'last_test_at' => $lastTestAt, + 'is_active' => !empty($settings['is_active']), + 'last_test_at' => trim((string) ($settings['last_test_at'] ?? '')), 'configure_url' => '/settings/integrations/fakturownia', 'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'), ]; diff --git a/src/Modules/Settings/InvoiceConfigController.php b/src/Modules/Settings/InvoiceConfigController.php index 27efcc0..41410bc 100644 --- a/src/Modules/Settings/InvoiceConfigController.php +++ b/src/Modules/Settings/InvoiceConfigController.php @@ -26,7 +26,7 @@ final class InvoiceConfigController public function index(Request $request): Response { $configs = $this->repository->listAll(); - $accounts = $this->fakturownia->findAll(); + $settings = $this->fakturownia->getSettings(); $html = $this->template->render('settings/accounting-invoices', [ 'title' => 'Konfiguracje faktur', @@ -35,7 +35,7 @@ final class InvoiceConfigController 'user' => $this->auth->user(), 'csrfToken' => Csrf::token(), 'configs' => $configs, - 'fakturowniaAccounts' => $accounts, + 'fakturowniaSettings' => $settings, 'successMessage' => (string) Flash::get('accounting.invoices.save', ''), 'errorMessage' => (string) Flash::get('accounting.invoices.error', ''), ], 'layouts/app'); @@ -53,10 +53,7 @@ final class InvoiceConfigController return Response::redirect('/settings/accounting/invoices'); } - $accounts = array_values(array_filter( - $this->fakturownia->findAll(), - static fn (array $row) => !empty($row['is_active']) - )); + $settings = $this->fakturownia->getSettings(); $html = $this->template->render('settings/accounting-invoice-edit', [ 'title' => $config === null ? 'Nowa konfiguracja faktury' : 'Edycja konfiguracji faktury', @@ -65,7 +62,7 @@ final class InvoiceConfigController 'user' => $this->auth->user(), 'csrfToken' => Csrf::token(), 'config' => $config, - 'fakturowniaAccounts' => $accounts, + 'fakturowniaSettings' => $settings, 'successMessage' => (string) Flash::get('accounting.invoices.save', ''), 'errorMessage' => (string) Flash::get('accounting.invoices.error', ''), ], 'layouts/app'); @@ -96,7 +93,7 @@ final class InvoiceConfigController 'payment_to_days' => (int) $request->input('payment_to_days', 7), 'default_kind' => (string) $request->input('default_kind', 'vat'), 'is_delegated' => $request->input('is_delegated', ''), - 'integration_id' => $request->input('integration_id', ''), + 'integration_id' => $this->fakturownia->getIntegrationId(), 'is_active' => $request->input('is_active', ''), ]); diff --git a/src/Modules/Settings/InvoiceConfigRepository.php b/src/Modules/Settings/InvoiceConfigRepository.php index d68c7f4..daa6eaf 100644 --- a/src/Modules/Settings/InvoiceConfigRepository.php +++ b/src/Modules/Settings/InvoiceConfigRepository.php @@ -13,7 +13,10 @@ final class InvoiceConfigRepository { use ToggleableRepositoryTrait; - public function __construct(private readonly PDO $pdo) + public function __construct( + private readonly PDO $pdo, + private readonly ?FakturowniaIntegrationRepository $fakturownia = null + ) { } @@ -116,9 +119,10 @@ final class InvoiceConfigRepository : null; if ($isDelegated === 1) { - if ($integrationId === null || $integrationId <= 0) { + $integrationId = $this->globalFakturowniaIntegrationId(); + if ($integrationId <= 0) { throw new IntegrationConfigException( - 'Przy delegacji wystawiania do Fakturowni musisz wskazac konto Fakturowni.' + 'Przed delegacja faktur skonfiguruj globalna integracje Fakturownia.' ); } if (!$this->isFakturowniaIntegration($integrationId)) { @@ -213,6 +217,25 @@ final class InvoiceConfigRepository } } + private function globalFakturowniaIntegrationId(): int + { + if ($this->fakturownia !== null) { + return $this->fakturownia->getIntegrationId(); + } + + try { + $statement = $this->pdo->prepare( + 'SELECT id FROM integrations WHERE type = :type ORDER BY id ASC LIMIT 1' + ); + $statement->execute(['type' => 'fakturownia']); + $value = $statement->fetchColumn(); + } catch (Throwable) { + return 0; + } + + return is_numeric($value) ? (int) $value : 0; + } + private function hasInvoices(int $configId): bool { try {