feat(121+122): smsplanet conversation, notifications, default footer

Phase 121 — SMSPLANET Conversation + Notifications:
- migration 20260512_000110 adds smsplanet conversation + notifications tables
- src/Modules/Sms (SmsConversationService, SmsMessageRepository, SmsplanetWebhookController)
- src/Modules/Notifications (Repository, Controller, ApiController)
- order SMS tab, notification center, sender mode, inbound webhook
- public notifications.js + layouts/app.php integration

Phase 122 — SMSPLANET Default SMS Footer:
- migration 20260512_000111 adds smsplanet_integration_settings.default_footer
- footer appended to test SMS and order SMS, validated against 918 char limit
- settings textarea + compact order SMS note when footer configured

Bundled (could not split per-phase without hunk staging):
- routes/web.php (also carries Phase 118 fakturownia redirects)
- DOCS/{ARCHITECTURE,DB_SCHEMA,TECH_CHANGELOG}.md (118 + 121 + 122 entries)
- .paul/codebase/{architecture,db_schema,tech_changelog}.md (118 + 121 + 122)
- .paul/STATE.md, ROADMAP.md, changelog/2026-05-12.md (UNIFY closure)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:37:41 +02:00
parent 8f14851d85
commit 360eef128d
34 changed files with 2538 additions and 128 deletions

View File

@@ -19,7 +19,7 @@ HTTP Request
| Layer | Location | Responsibility |
|-------|----------|----------------|
| Entry | `public/index.php` | Bootstrap only |
| Routes | `routes/web.php` (581 lines) | All ~80 routes; manual DI wiring |
| Routes | `routes/web.php` | All routes; manual DI wiring |
| Core | `src/Core/` (25 files) | Framework infrastructure |
| Controllers | `src/Modules/*/Controller.php` | Request parsing → response |
| Services | `src/Modules/*/Service.php` | Business logic |
@@ -40,6 +40,8 @@ HTTP Request
| **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 |
| **Sms** | 3 | `SmsMessageRepository`, `SmsConversationService`, `SmsplanetWebhookController` | SMSPLANET outbound order SMS, inbound webhook parsing, order matching |
| **Notifications** | 3 | `NotificationRepository`, `NotificationController`, `NotificationApiController` | Global notification history, unread polling API, mark-read actions |
| **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 |
@@ -61,6 +63,12 @@ HTTP Request
- Chart 2 displays monthly gross order values per selected integration plus a `Razem` line.
- The PHP view keeps table fallbacks under both charts, so the data remains visible if JavaScript fails.
### Notifications (`public/assets/js/modules/notifications.js`)
- Loaded globally from `resources/views/layouts/app.php`; activates only when the topbar notification button exists.
- Polls `/api/notifications/unread` every 30 seconds and updates the unread badge.
- Requests browser Notification API permission only after user interaction with the notification button.
- Shows native browser notifications for newly seen unread items when permission is granted; click navigates to `target_url`.
## Key Data Flows
### Order Lifecycle
@@ -83,6 +91,13 @@ HTTP Request
1. **Generate**`ReceiptController::store()``ReceiptService::generateReceipt()``ReceiptRepository::insert()` + Dompdf PDF
2. **Email**`EmailSendingService::send()``VariableResolver::resolve()``AttachmentGenerator::generatePdf()` → PHPMailer SMTP
### SMSPLANET Conversation
1. **Settings**`/settings/integrations/smsplanet` stores auth, text sender, `sender_mode`, optional 2WAY `sender_phone`, and optional global `default_footer`.
2. **Outbound from order**`/orders/{id}/sms/send``OrdersController::sendSms()``SmsConversationService::sendFromOrder()` appends `default_footer` when configured, validates the final body against 918 characters, sends through `SmsplanetApiClient::sendSms()`, and stores the final sent body in `sms_messages`.
3. **Inbound webhook** — public `/webhooks/smsplanet/inbound` accepts SMSPLANET 2WAY `POST application/x-www-form-urlencoded` with `message=<JSON>`, plus fallback POST/GET payloads → `SmsplanetWebhookController::inbound()``SmsConversationService::receiveSmsplanetWebhook()`; successful 2WAY receipt returns plain `OK`.
4. **Order matching** — inbound sender phone is normalized and matched to the latest order by `order_addresses.phone`.
5. **Notification** — inbound SMS creates `notifications.type='sms_inbound'` with a target URL to the order SMS tab when an order was matched.
### Automation Rules
1. **Setup**`AutomationController``AutomationRepository::insertRule()`
2. **Trigger**`AutomationService::executeForOrder()` → evaluates trigger (`order_status_changed`, `order_status_aged`) → runs action (send email, update status)
@@ -174,7 +189,7 @@ tests/
- Zarzadza pojedynczym rekordem `smsplanet_integration_settings` (`id=1`) i bazowym wpisem `integrations` typu `smsplanet`.
- Obsluguje dwie metody autoryzacji: Bearer token oraz `key` + `password`.
- Szyfruje token, klucz API i haslo przez `IntegrationSecretCipher`; formularz widzi tylko flagi `has_api_token`, `has_api_key` i `has_api_password`.
- Udostepnia `getCredentials()` tylko dla kompletnej i aktywnej konfiguracji testowej wysylki SMS.
- Udostepnia `getCredentials()` tylko dla kompletnej i aktywnej konfiguracji testowej wysylki SMS, razem z opcjonalna `default_footer`.
### SmsplanetApiClient (`src/Modules/Settings/SmsplanetApiClient.php`)
- Wykonuje `POST https://api2.smsplanet.pl/sms` jako `application/x-www-form-urlencoded`.
@@ -185,10 +200,35 @@ tests/
### SmsplanetIntegrationController (`src/Modules/Settings/SmsplanetIntegrationController.php`)
- Endpointy: `GET /settings/integrations/smsplanet`, `POST /settings/integrations/smsplanet/save`, `POST /settings/integrations/smsplanet/test`.
- `test` realnie wysyla SMS z edytowalna trescia i zapisuje wynik w `integrations.last_test_*`.
- Testowa wysylka dopisuje `default_footer` przed wywolaniem SMSPLANET i waliduje finalna tresc w limicie 918 znakow.
### IntegrationsHubController
- Dodaje wiersz SMSPLANET do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
## Phase 118 - Fakturownia Single Instance
### FakturowniaIntegrationRepository (`src/Modules/Settings/FakturowniaIntegrationRepository.php`)
- Zarzadza pojedynczym globalnym rekordem `fakturownia_integration_settings` (`id=1`) i jednym bazowym wpisem `integrations.type='fakturownia'`.
- `getSettings()` zwraca dane formularza, flagi `has_api_token`, aktywnosc i wynik ostatniego testu.
- `saveSettings()` aktualizuje globalna konfiguracje; pusty `api_token` zachowuje zapisany sekret.
- `findAll()` zostaje jako kompatybilny wrapper zwracajacy liste z jednym elementem dla starszych wywolan.
- `getIntegrationId()` jest zrodlem prawdy dla `invoice_configs.integration_id` przy delegacji faktur.
### FakturowniaIntegrationController
- Endpointy aktywne: `GET /settings/integrations/fakturownia`, `POST /settings/integrations/fakturownia/save`, `POST /settings/integrations/fakturownia/test`.
- Legacy `/new` i `/edit` przekierowuja na globalna konfiguracje; delete z UI nie jest oferowany.
- Widok `resources/views/settings/fakturownia.php` pokazuje jeden formularz konfiguracji oraz panel testu polaczenia.
### InvoiceConfigRepository + InvoiceConfigController
- Przy `is_delegated=1` zapis konfiguracji ignoruje wieloinstancyjny wybor konta i ustawia `integration_id` na globalny Fakturownia id.
- Kolumna `invoice_configs.integration_id` zostaje dla kompatybilnosci z `InvoiceService` i historia wystawionych faktur.
- Widok konfiguracji faktury pokazuje status globalnej Fakturowni zamiast selecta kont.
### Migration 20260512_000109
- Wybiera aktywna instancje Fakturowni jako zachowana; fallback: najczesciej uzywana w `invoice_configs`, potem najnizsze id.
- Przepina delegowane `invoice_configs.integration_id` na zachowana instancje i zeruje `integration_id` dla lokalnych konfiguracji.
- Usuwa nadmiarowe rekordy `fakturownia_integration_settings` i `integrations.type='fakturownia'` po przepieciu zaleznosci.
## Phase 108 — Delivery Status Management
### DeliveryStatusRepository (`src/Modules/Shipments/DeliveryStatusRepository.php`)

View File

@@ -1,6 +1,6 @@
# Database Schema
**Updated:** 2026-05-12 | **Total tables:** 60 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
**Updated:** 2026-05-12 | **Total tables:** 62 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
---
@@ -243,7 +243,7 @@ UNIQUE: `(integration_id, external_product_id, external_variant_id)`
**orders** — Imported orders from sales channels
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `id` | BIGINT UNSIGNED | NO | PK |
| `internal_order_number` | VARCHAR(11) | YES | UNIQUE, auto-assigned |
| `integration_id` | INT UNSIGNED | NO | FK → integrations(id) CASCADE |
| `external_order_id` | VARCHAR(64) | NO | |
@@ -558,6 +558,23 @@ UNIQUE: `(type, name)`
---
**fakturownia_integration_settings** - Fakturownia account credentials (Phase 118; fixed 1 row)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK, always 1 after `20260512_000109_fakturownia_single_instance.sql` |
| `integration_id` | INT UNSIGNED | NO | UNIQUE, FK -> integrations(id) CASCADE; single `integrations.type='fakturownia'` row |
| `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 global Fakturownia settings row. Phase 118 migration keeps the active Fakturownia integration, rewires delegated `invoice_configs.integration_id` to it, and removes extra Fakturownia integration rows.
---
**hostedsms_integration_settings** - HostedSMS account credentials (Phase 116; fixed 1 row)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
@@ -583,9 +600,12 @@ UNIQUE: `(integration_id)` - one global HostedSMS settings row.
| `api_token_encrypted` | TEXT | YES | AES-encrypted Bearer token via `IntegrationSecretCipher` |
| `api_key_encrypted` | TEXT | YES | AES-encrypted API key via `IntegrationSecretCipher` |
| `api_password_encrypted` | TEXT | YES | AES-encrypted API password via `IntegrationSecretCipher` |
| `sender` | VARCHAR(32) | YES | SMSPLANET `from` sender |
| `sender` | VARCHAR(32) | YES | Text sender / nadpis |
| `sender_mode` | VARCHAR(16) | NO | DEFAULT `text`; `text` uses `sender`, `phone` uses `sender_phone` |
| `sender_phone` | VARCHAR(32) | YES | SMSPLANET 2WAY phone number |
| `clear_polish` | TINYINT(1) | NO | DEFAULT 0 |
| `transactional` | TINYINT(1) | NO | DEFAULT 0 |
| `default_footer` | TEXT | YES | Optional global footer appended to SMSPLANET test and order SMS |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
@@ -593,6 +613,44 @@ UNIQUE: `(integration_id)` - one global SMSPLANET settings row.
---
**sms_messages** - SMSPLANET inbound/outbound conversation history (Phase 121)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `direction` | VARCHAR(16) | NO | `inbound` or `outbound` |
| `provider` | VARCHAR(32) | NO | DEFAULT `smsplanet` |
| `order_id` | BIGINT UNSIGNED | YES | FK -> orders(id) SET NULL |
| `from_phone` | VARCHAR(64) | NO | Original sender value |
| `from_phone_normalized` | VARCHAR(32) | NO | Digits-only sender for matching/indexes |
| `to_phone` | VARCHAR(64) | NO | Original recipient value |
| `to_phone_normalized` | VARCHAR(32) | NO | Digits-only recipient for matching/indexes |
| `body` | TEXT | NO | SMS body |
| `message_id` | VARCHAR(128) | YES | Provider message id |
| `status` | VARCHAR(32) | NO | `received`, `sent`, `failed` |
| `raw_payload_json` | JSON | YES | Webhook payload or send result snapshot |
| `created_by` | INT UNSIGNED | YES | FK -> users(id) SET NULL |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
Indexes: `sms_messages_order_created_idx`, `sms_messages_from_normalized_idx`, `sms_messages_to_normalized_idx`, `sms_messages_provider_message_idx`
**notifications** - Global operator notification center (Phase 121)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `type` | VARCHAR(64) | NO | e.g. `sms_inbound` |
| `title` | VARCHAR(190) | NO | |
| `body` | VARCHAR(500) | NO | Concise notification text |
| `target_url` | VARCHAR(500) | YES | Deep link, usually `/orders/{id}?tab=sms` |
| `related_order_id` | BIGINT UNSIGNED | YES | FK -> orders(id) SET NULL |
| `related_sms_message_id` | BIGINT UNSIGNED | YES | FK -> sms_messages(id) SET NULL |
| `read_at` | DATETIME | YES | NULL means unread |
| `created_at` | DATETIME | NO | |
Indexes: `notifications_unread_created_idx`, `notifications_order_idx`, `notifications_sms_message_idx`
---
## Accounting / Receipts
**receipt_configs** — Receipt generation configurations
@@ -640,6 +698,60 @@ UNIQUE: `(config_id, year, month)`
---
## Invoices
**invoice_configs** - Invoice generation configurations
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `name` | VARCHAR(128) | NO | |
| `integration_id` | INT UNSIGNED | YES | FK -> integrations(id) SET NULL; delegated configs always point to the single global Fakturownia row |
| `is_delegated` | TINYINT(1) | NO | DEFAULT 0 |
| `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` |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**invoices** - Generated invoices
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT 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 for delegated invoices |
| `external_pdf_url` | VARCHAR(500) | YES | Fakturownia PDF URL for delegated invoices |
| `kind` | VARCHAR(32) | NO | DEFAULT `vat` |
| `created_by` | INT UNSIGNED | YES | |
| `created_at` | DATETIME | NO | |
**invoice_number_counters** - Sequential numbering per config/period
| 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

View File

@@ -1,5 +1,75 @@
# Technical Changelog
## 2026-05-12 - SMSPLANET Inbound Webhook Fix
**Co zrobiono:**
- Poprawiono dopasowanie przychodzacych SMSPLANET po telefonie: `SmsMessageRepository::findLatestOrderIdByPhones()` nie odwoluje sie juz do nieistniejacej w produkcyjnej bazie kolumny `orders.buyer_phone`, tylko do `order_addresses.phone`.
- Dodano obsluge `GET /webhooks/smsplanet/inbound` obok POST, bo sekcja odbierania SMS 2WAY w dokumentacji SMSPLANET opisuje przekierowanie na URL bez jednoznacznego kontraktu metody.
- `SmsplanetWebhookController` obsluguje format 2WAY `POST application/x-www-form-urlencoded` z parametrem `message=<JSON>`, scala JSON z body z parametrami requestu takze wtedy, gdy URL ma query string, i po sukcesie zwraca plain text `OK`.
**Dlaczego:**
- Publiczny endpoint byl osiagalny jako POST, ale odpowiedz SMS nie mogla zostac zapisana przez blad SQL `Unknown column 'o.buyer_phone'`. GET na ten sam URL zwracal 404.
**BREAKING / migracja:**
- Brak migracji. Zmiana usuwa bledne zalozenie o schemacie produkcyjnej tabeli `orders`.
## 2026-05-12 - Phase 122 Plan 01: SMSPLANET Default SMS Footer
**Co zrobiono:**
- Dodano migracje `20260512_000111_smsplanet_default_footer.sql` z kolumna `smsplanet_integration_settings.default_footer`.
- Rozszerzono konfiguracje SMSPLANET o opcjonalna stopke SMS z limitem 300 znakow i zapisem oddzielnym od danych autoryzacji/nadawcy.
- Testowa wysylka SMSPLANET oraz wysylka SMS z zamowienia dopinaja stopke przez pusta linie, waliduja finalna tresc w limicie 918 znakow i nie wywoluja API przy przekroczeniu limitu.
- Historia `sms_messages.body` zapisuje finalna tresc wyslana do SMSPLANET, czyli razem ze stopka, gdy jest skonfigurowana.
- Widok rozmowy SMS w zamowieniu pokazuje kompaktowa informacje, ze stopka zostanie dodana automatycznie.
**Dlaczego:**
- Operator ma utrzymywac jeden wspolny podpis firmy bez recznego kopiowania go do kazdej wiadomosci SMS.
**BREAKING / migracja:**
- Brak. Pusta stopka zachowuje dotychczasowe tresci SMS bez zmian.
## 2026-05-12 - Phase 121 Plan 01: SMSPLANET Conversation + Notifications
**Co zrobiono:**
- Dodano migracje `20260512_000110_smsplanet_conversation_notifications.sql` z tabelami `sms_messages`, `notifications` oraz polami `sender_mode` i `sender_phone` w `smsplanet_integration_settings`.
- Rozszerzono SMSPLANET o wybor nadawcy: nadpis albo numer 2WAY, bez tymczasowego override testowego numeru.
- Dodano publiczny webhook `/webhooks/smsplanet/inbound`, zapis przychodzacych SMS, dopasowanie do ostatniego zamowienia po telefonie i tworzenie globalnego powiadomienia.
- Dodano zakladke SMS w szczegolach zamowienia z historia rozmowy i formularzem wysylki.
- Dodano centrum powiadomien `/notifications`, API pollingu `/api/notifications/unread`, badge w topbarze i progresywne powiadomienia przegladarki.
- Poprawiono migracje po pierwszej probie na bazie: rzeczywiste `orders.id` ma typ `BIGINT UNSIGNED`, wiec `sms_messages.order_id` i `notifications.related_order_id` tez musza miec `BIGINT UNSIGNED`.
**Dlaczego:**
- Operator ma prowadzic dwukierunkowa rozmowe SMSPLANET bez opuszczania zamowienia, a nowe odpowiedzi klientow maja byc widoczne globalnie.
**BREAKING / migracja:**
- Brak. Webhook SMSPLANET w tej fazie celowo nie weryfikuje podpisu.
## 2026-05-12 - SMSPLANET Test Sender Override
**Co zrobiono:**
- Tymczasowo ustawiono testowa wysylke SMSPLANET na `from=48532963363` w `SmsplanetIntegrationController::test()`.
- Zapis konfiguracji SMSPLANET pozostaje bez zmian; override dotyczy tylko endpointu `/settings/integrations/smsplanet/test`.
**Dlaczego:**
- Operator sprawdza odbior odpowiedzi SMS z numeru 2WAY zamiast tekstowego nadpisu.
## 2026-05-12 - Phase 118 Plan 01: Fakturownia Single Instance
**Co zrobiono:**
- Dodano migracje `20260512_000109_fakturownia_single_instance.sql`, ktora wybiera aktywna instancje Fakturowni, przepina delegowane `invoice_configs.integration_id` na jeden globalny rekord i usuwa nadmiarowe konta Fakturowni po przepieciu zaleznosci.
- Przebudowano `FakturowniaIntegrationRepository` na model jednej globalnej konfiguracji (`getSettings()`, `saveSettings()`, `getIntegrationId()`, `getCredentials()`), z kompatybilnym `findAll()` zwracajacym jeden element.
- Uproszczono `FakturowniaIntegrationController` i widok `/settings/integrations/fakturownia` do pojedynczego formularza konfiguracji i testu polaczenia.
- Hub integracji pokazuje Fakturownie jako jedna instancje, bez licznika kont.
- Zapis delegowanej konfiguracji faktury ustawia `invoice_configs.integration_id` na globalny rekord Fakturowni; UI konfiguracji faktury nie pokazuje juz selecta kont.
- Zaktualizowano `DOCS/DB_SCHEMA.md` i `DOCS/ARCHITECTURE.md` o kontrakt pojedynczej Fakturowni.
**Dlaczego:**
- Operator chce obslugiwac Fakturownie tak jak HostedSMS/SMSPLANET: jedna konfiguracja globalna zamiast wielu instancji.
- Zachowanie `invoice_configs.integration_id` ogranicza ryzyko regresji w `InvoiceService` i historii faktur, a jednoczesnie usuwa wieloinstancyjny wybor z UI.
**BREAKING / migracja:**
- Po migracji nie ma juz wielu kont Fakturowni w UI. Jesli baza miala wiele rekordow `integrations.type='fakturownia'`, zachowany zostaje aktywny rekord (fallback: uzywany przez konfiguracje faktur, potem najnizsze id), a pozostale sa usuwane.
## 2026-05-12 - Phase 117 Plan 01: SMSPLANET Integration Settings + Test SMS
**Co zrobiono:**